Page MenuHomePhorge

No OneTemporary

This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/channel/PhutilChannel.php b/src/channel/PhutilChannel.php
index 2827de10..5ea98887 100644
--- a/src/channel/PhutilChannel.php
+++ b/src/channel/PhutilChannel.php
@@ -1,428 +1,428 @@
<?php
/**
* Wrapper around streams, pipes, and other things that have basic read/write
* I/O characteristics.
*
* Channels include buffering, so you can do fire-and-forget writes and reads
* without worrying about network/pipe buffers. Use @{method:read} and
* @{method:write} to read and write.
*
* Channels are nonblocking and provide a select()-oriented interface so you
* can reasonably write server-like and daemon-like things with them. Use
* @{method:waitForAny} to select channels.
*
* Channel operations other than @{method:update} generally operate on buffers.
* Writes and reads affect buffers, while @{method:update} flushes output
* buffers and fills input buffers.
*
* Channels are either "open" or "closed". You can detect that a channel has
* closed by calling @{method:isOpen} or examining the return value of
* @{method:update}.
*
* NOTE: Channels are new (as of June 2012) and subject to interface changes.
*
* @task io Reading and Writing
* @task wait Waiting for Activity
* @task update Responding to Activity
* @task impl Channel Implementation
*/
abstract class PhutilChannel extends Phobject {
private $ibuf = '';
private $obuf;
private $name;
private $readBufferSize;
public function __construct() {
$this->obuf = new PhutilRope();
}
/* -( Reading and Writing )------------------------------------------------ */
/**
* Read from the channel. A channel defines the format of data that is read
* from it, so this method may return strings, objects, or anything else.
*
* The default implementation returns bytes.
*
* @return wild Data from the channel, normally bytes.
*
* @task io
*/
public function read() {
$result = $this->ibuf;
$this->ibuf = '';
return $result;
}
/**
* Write to the channel. A channel defines what data format it accepts,
* so this method may take strings, objects, or anything else.
*
* The default implementation accepts bytes.
*
* @param wild $bytes Data to write to the channel, normally bytes.
- * @return this
+ * @return $this
*
* @task io
*/
public function write($bytes) {
if (!is_scalar($bytes)) {
throw new Exception(
pht(
'%s may only write strings!',
__METHOD__.'()'));
}
$this->obuf->append($bytes);
return $this;
}
/* -( Waiting for Activity )----------------------------------------------- */
/**
* Wait for any activity on a list of channels. Convenience wrapper around
* @{method:waitForActivity}.
*
* @param list<PhutilChannel> $channels A list of channels to wait for.
* @param dict $options (optional) Options, see above.
* @return void
*
* @task wait
*/
public static function waitForAny(array $channels, array $options = array()) {
return self::waitForActivity($channels, $channels, $options);
}
/**
* Wait (using select()) for channels to become ready for reads or writes.
* This method blocks until some channel is ready to be updated.
*
* It does not provide a way to determine which channels are ready to be
* updated. The expectation is that you'll just update every channel. This
* might change eventually.
*
* Available options are:
*
* - 'read' (list<stream>) Additional streams to select for read.
* - 'write' (list<stream>) Additional streams to select for write.
* - 'except' (list<stream>) Additional streams to select for except.
* - 'timeout' (float) Select timeout, defaults to 1.
*
* NOTE: Extra streams must be //streams//, not //sockets//, because this
* method uses `stream_select()`, not `socket_select()`.
*
* @param list<PhutilChannel> $reads List of channels to wait for reads on.
* @param list<PhutilChannel> $writes List of channels to wait for writes on.
* @param dict $options (optional) Options, see above.
* @return void
*
* @task wait
*/
public static function waitForActivity(
array $reads,
array $writes,
array $options = array()) {
assert_instances_of($reads, __CLASS__);
assert_instances_of($writes, __CLASS__);
$read = idx($options, 'read', array());
$write = idx($options, 'write', array());
$except = idx($options, 'except', array());
$wait = idx($options, 'timeout', 1);
// TODO: It would be nice to just be able to categorically reject these as
// unselectable.
foreach (array($reads, $writes) as $channels) {
foreach ($channels as $channel) {
$r_sockets = $channel->getReadSockets();
$w_sockets = $channel->getWriteSockets();
// If any channel has no read sockets and no write sockets, assume it
// isn't selectable and return immediately (effectively degrading to a
// busy wait).
if (!$r_sockets && !$w_sockets) {
return false;
}
}
}
foreach ($reads as $channel) {
// If any of the read channels have data in read buffers, return
// immediately. If we don't, we risk running select() on a bunch of
// sockets which won't become readable because the data the application
// expects is already in a read buffer.
if (!$channel->isReadBufferEmpty()) {
return;
}
$r_sockets = $channel->getReadSockets();
foreach ($r_sockets as $socket) {
$read[] = $socket;
$except[] = $socket;
}
}
foreach ($writes as $channel) {
if ($channel->isWriteBufferEmpty()) {
// If the channel's write buffer is empty, don't select the write
// sockets, since they're writable immediately.
$w_sockets = array();
} else {
$w_sockets = $channel->getWriteSockets();
}
foreach ($w_sockets as $socket) {
$write[] = $socket;
$except[] = $socket;
}
}
if (!$read && !$write && !$except) {
return false;
}
$wait_sec = (int)$wait;
$wait_usec = 1000000 * ($wait - $wait_sec);
@stream_select($read, $write, $except, $wait_sec, $wait_usec);
}
/* -( Responding to Activity )--------------------------------------------- */
/**
* Updates the channel, filling input buffers and flushing output buffers.
* Returns false if the channel has closed.
*
* @return bool True if the channel is still open.
*
* @task update
*/
public function update() {
$maximum_read = PHP_INT_MAX;
if ($this->readBufferSize !== null) {
$maximum_read = ($this->readBufferSize - strlen($this->ibuf));
}
while ($maximum_read > 0) {
$in = $this->readBytes($maximum_read);
if (!strlen($in)) {
// Reading is blocked for now.
break;
}
$this->ibuf .= $in;
$maximum_read -= strlen($in);
}
while ($this->obuf->getByteLength()) {
$len = $this->writeBytes($this->obuf->getAnyPrefix());
if (!$len) {
// Writing is blocked for now.
break;
}
$this->obuf->removeBytesFromHead($len);
}
return $this->isOpen();
}
/* -( Channel Implementation )--------------------------------------------- */
/**
* Set a channel name. This is primarily intended to allow you to debug
* channel code more easily, by naming channels something meaningful.
*
* @param string $name Channel name.
- * @return this
+ * @return $this
*
* @task impl
*/
public function setName($name) {
$this->name = $name;
return $this;
}
/**
* Get the channel name, as set by @{method:setName}.
*
* @return string Name of the channel.
*
* @task impl
*/
public function getName() {
return coalesce($this->name, get_class($this));
}
/**
* Test if the channel is open: active, can be read from and written to, etc.
*
* @return bool True if the channel is open.
*
* @task impl
*/
abstract public function isOpen();
/**
* Close the channel for writing.
*
* @return void
* @task impl
*/
abstract public function closeWriteChannel();
/**
* Test if the channel is open for reading.
*
* @return bool True if the channel is open for reading.
*
* @task impl
*/
public function isOpenForReading() {
return $this->isOpen();
}
/**
* Test if the channel is open for writing.
*
* @return bool True if the channel is open for writing.
*
* @task impl
*/
public function isOpenForWriting() {
return $this->isOpen();
}
/**
* Read from the channel's underlying I/O.
*
* @param int $length Maximum number of bytes to read.
* @return string Bytes, if available.
*
* @task impl
*/
abstract protected function readBytes($length);
/**
* Write to the channel's underlying I/O.
*
* @param string $bytes Bytes to write.
* @return int Number of bytes written.
*
* @task impl
*/
abstract protected function writeBytes($bytes);
/**
* Get sockets to select for reading.
*
* @return list<stream> Read sockets.
*
* @task impl
*/
protected function getReadSockets() {
return array();
}
/**
* Get sockets to select for writing.
*
* @return list<stream> Write sockets.
*
* @task impl
*/
protected function getWriteSockets() {
return array();
}
/**
* Set the maximum size of the channel's read buffer. Reads will artificially
* block once the buffer reaches this size until the in-process buffer is
* consumed.
*
* @param int|null $size Maximum read buffer size, or `null` for a limitless
* buffer.
- * @return this
+ * @return $this
* @task impl
*/
public function setReadBufferSize($size) {
$this->readBufferSize = $size;
return $this;
}
/**
* Test state of the read buffer.
*
* @return bool True if the read buffer is empty.
*
* @task impl
*/
public function isReadBufferEmpty() {
return (strlen($this->ibuf) == 0);
}
/**
* Test state of the write buffer.
*
* @return bool True if the write buffer is empty.
*
* @task impl
*/
public function isWriteBufferEmpty() {
return !$this->getWriteBufferSize();
}
/**
* Get the number of bytes we're currently waiting to write.
*
* @return int Number of waiting bytes.
*
* @task impl
*/
public function getWriteBufferSize() {
return $this->obuf->getByteLength();
}
/**
* Wait for any buffered writes to complete. This is a blocking call. When
* the call returns, the write buffer will be empty.
*
* @task impl
*/
public function flush() {
while (!$this->isWriteBufferEmpty()) {
self::waitForAny(array($this));
if (!$this->update()) {
throw new Exception(pht('Channel closed while flushing output!'));
}
}
return $this;
}
}
diff --git a/src/channel/PhutilExecChannel.php b/src/channel/PhutilExecChannel.php
index 0f862103..8e736178 100644
--- a/src/channel/PhutilExecChannel.php
+++ b/src/channel/PhutilExecChannel.php
@@ -1,173 +1,173 @@
<?php
/**
* Channel on an underlying @{class:ExecFuture}. For a description of channels,
* see @{class:PhutilChannel}.
*
* For example, you can open a channel on `nc` like this:
*
* $future = new ExecFuture('nc example.com 80');
* $channel = new PhutilExecChannel($future);
*
* $channel->write("GET / HTTP/1.0\n\n");
* while (true) {
* echo $channel->read();
*
* PhutilChannel::waitForAny(array($channel));
* if (!$channel->update()) {
* // Break out of the loop when the channel closes.
* break;
* }
* }
*
* This script makes an HTTP request to "example.com". This example is heavily
* contrived. In most cases, @{class:ExecFuture} and other futures constructs
* offer a much easier way to solve problems which involve system commands, and
* @{class:HTTPFuture} and other HTTP constructs offer a much easier way to
* solve problems which involve HTTP.
*
* @{class:PhutilExecChannel} is generally useful only when a program acts like
* a server but performs I/O on stdin/stdout, and you need to act like a client
* or interact with the program at the same time as you manage traditional
* socket connections. Examples are Mercurial operating in "cmdserve" mode, git
* operating in "receive-pack" mode, etc. It is unlikely that any reasonable
* use of this class is concise enough to make a short example out of, so you
* get a contrived one instead.
*
* See also @{class:PhutilSocketChannel}, for a similar channel that uses
* sockets for I/O.
*
* Since @{class:ExecFuture} already supports buffered I/O and socket selection,
* the implementation of this class is fairly straightforward.
*
* @task construct Construction
*/
final class PhutilExecChannel extends PhutilChannel {
private $future;
private $stderrHandler;
/* -( Construction )------------------------------------------------------- */
/**
* Construct an exec channel from a @{class:ExecFuture}. The future should
* **NOT** have been started yet (e.g., with `isReady()` or `start()`),
* because @{class:ExecFuture} closes stdin by default when futures start.
* If stdin has been closed, you will be unable to write on the channel.
*
* @param ExecFuture $future Future to use as an underlying I/O source.
* @task construct
*/
public function __construct(ExecFuture $future) {
parent::__construct();
// Make an empty write to keep the stdin pipe open. By default, futures
// close this pipe when they start.
$future->write('', $keep_pipe = true);
// Start the future so that reads and writes work immediately.
$future->isReady();
$this->future = $future;
}
public function __destruct() {
if (!$this->future->isReady()) {
$this->future->resolveKill();
}
}
public function update() {
$this->future->isReady();
return parent::update();
}
public function isOpen() {
return !$this->future->isReady();
}
protected function readBytes($length) {
list($stdout, $stderr) = $this->future->read();
$this->future->discardBuffers();
if (strlen($stderr)) {
if ($this->stderrHandler) {
call_user_func($this->stderrHandler, $this, $stderr);
} else {
throw new Exception(
pht('Unexpected output to stderr on exec channel: %s', $stderr));
}
}
return $stdout;
}
public function write($bytes) {
$this->future->write($bytes, $keep_pipe = true);
}
public function closeWriteChannel() {
$this->future->write('', $keep_pipe = false);
}
protected function writeBytes($bytes) {
throw new Exception(pht('%s can not write bytes directly!', 'ExecFuture'));
}
protected function getReadSockets() {
return $this->future->getReadSockets();
}
protected function getWriteSockets() {
return $this->future->getWriteSockets();
}
public function isReadBufferEmpty() {
// Check both the channel and future read buffers, since either could have
// data.
return parent::isReadBufferEmpty() && $this->future->isReadBufferEmpty();
}
public function setReadBufferSize($size) {
// NOTE: We may end up using 2x the buffer size here, one inside
// ExecFuture and one inside the Channel. We could tune this eventually, but
// it should be fine for now.
parent::setReadBufferSize($size);
$this->future->setReadBufferSize($size);
return $this;
}
public function isWriteBufferEmpty() {
return $this->future->isWriteBufferEmpty();
}
public function getWriteBufferSize() {
return $this->future->getWriteBufferSize();
}
/**
* If the wrapped @{class:ExecFuture} outputs data to stderr, we normally
* throw an exception. Instead, you can provide a callback handler that will
* be invoked and passed the data. It should have this signature:
*
* function f(PhutilExecChannel $channel, $stderr) {
* // ...
* }
*
* The `$channel` will be this channel object, and `$stderr` will be a string
* with bytes received over stderr.
*
* You can set a handler which does nothing to effectively ignore and discard
* any output on stderr.
*
* @param callable $handler Handler to invoke when stderr data is received.
- * @return this
+ * @return $this
*/
public function setStderrHandler($handler) {
$this->stderrHandler = $handler;
return $this;
}
}
diff --git a/src/channel/PhutilProtocolChannel.php b/src/channel/PhutilProtocolChannel.php
index 1670857e..80d18a1c 100644
--- a/src/channel/PhutilProtocolChannel.php
+++ b/src/channel/PhutilProtocolChannel.php
@@ -1,139 +1,139 @@
<?php
/**
* Wraps a @{class:PhutilChannel} and implements a message-oriented protocol
* on top of it. A protocol channel behaves like a normal channel, except that
* @{method:read} and @{method:write} are message-oriented and the protocol
* channel handles encoding and parsing messages for transmission.
*
* @task io Reading and Writing
* @task protocol Protocol Implementation
* @task wait Waiting for Activity
*/
abstract class PhutilProtocolChannel extends PhutilChannelChannel {
private $messages = array();
/* -( Reading and Writing )------------------------------------------------ */
/**
* Read a message from the channel, if a message is available.
*
* @return wild A message, or null if no message is available.
*
* @task io
*/
public function read() {
$data = parent::read();
if (strlen($data)) {
$messages = $this->decodeStream($data);
foreach ($messages as $message) {
$this->addMessage($message);
}
}
if (!$this->messages) {
return null;
}
return array_shift($this->messages);
}
/**
* Write a message to the channel.
*
* @param wild $message Some message.
- * @return this
+ * @return $this
*
* @task io
*/
public function write($message) {
$bytes = $this->encodeMessage($message);
return parent::write($bytes);
}
/**
* Add a message to the queue. While you normally do not need to do this,
* you can use it to inject out-of-band messages.
*
* @param wild $message Some message.
- * @return this
+ * @return $this
*
* @task io
*/
public function addMessage($message) {
$this->messages[] = $message;
return $this;
}
/* -( Protocol Implementation )-------------------------------------------- */
/**
* Encode a message for transmission.
*
* @param wild $message Some message.
* @return string The message serialized into a wire format for
* transmission.
*
* @task protocol
*/
abstract protected function encodeMessage($message);
/**
* Decode bytes from the underlying channel into zero or more complete
* messages. The messages should be returned.
*
* This method is called as data is available. It will receive incoming
* data only once, and must buffer any data which represents only part of
* a message. Once a complete message is received, it can return the message
* and discard that part of the buffer.
*
* Generally, a protocol channel should maintain a read buffer, implement
* a parser in this method, and store parser state on the object to be able
* to process incoming data in small chunks.
*
* @param string $data One or more bytes from the underlying channel.
* @return list<wild> Zero or more parsed messages.
*
* @task protocol
*/
abstract protected function decodeStream($data);
/* -( Waiting for Activity )----------------------------------------------- */
/**
* Wait for a message, blocking until one is available.
*
* @return wild A message.
*
* @task wait
*/
public function waitForMessage() {
while (true) {
$is_open = $this->update();
$message = $this->read();
if ($message !== null) {
return $message;
}
if (!$is_open) {
break;
}
self::waitForAny(array($this));
}
throw new Exception(pht('Channel closed while waiting for message!'));
}
}
diff --git a/src/console/view/PhutilConsoleView.php b/src/console/view/PhutilConsoleView.php
index 39fe58c6..a9a41724 100644
--- a/src/console/view/PhutilConsoleView.php
+++ b/src/console/view/PhutilConsoleView.php
@@ -1,112 +1,112 @@
<?php
abstract class PhutilConsoleView extends Phobject {
private $console;
abstract protected function drawView();
final public function setConsole(PhutilConsole $console) {
$this->console = $console;
return $this;
}
final public function getConsole() {
if ($this->console) {
return $this->console;
}
return PhutilConsole::getConsole();
}
/**
* Draw a view to the console.
*
- * @return this
+ * @return $this
* @task draw
*/
final public function draw() {
$string = $this->drawConsoleString();
$console = $this->getConsole();
$console->writeOut('%s', $string);
return $this;
}
/**
* Draw a view to a string and return it.
*
* @return string Console-printable string.
* @task draw
*/
final public function drawConsoleString() {
$view = $this->drawView();
$parts = $this->reduceView($view);
$out = array();
foreach ($parts as $part) {
$out[] = PhutilTerminalString::escapeStringValue($part, true);
}
return implode('', $out);
}
/**
* Reduce a view to a list of simple, unnested parts.
*
* @param wild $view Any drawable view.
* @return list<wild> List of unnested drawables.
* @task draw
*/
private function reduceView($view) {
if ($view instanceof PhutilConsoleView) {
$view = $view->drawView();
return $this->reduceView($view);
}
if (is_array($view)) {
$parts = array();
foreach ($view as $item) {
foreach ($this->reduceView($item) as $part) {
$parts[] = $part;
}
}
return $parts;
}
return array($view);
}
/* -( Drawing Utilities )-------------------------------------------------- */
/**
* @param list<wild> $parts List of views, one per line.
* @return wild Each view rendered on a separate line.
*/
final protected function drawLines(array $parts) {
$result = array();
foreach ($parts as $part) {
if ($part !== null) {
$result[] = $part;
$result[] = "\n";
}
}
return $result;
}
final protected function implode($separator, array $items) {
$result = array();
foreach ($items as $item) {
$result[] = $item;
$result[] = $separator;
}
array_pop($result);
return $result;
}
}
diff --git a/src/filesystem/FileFinder.php b/src/filesystem/FileFinder.php
index 4eb2c3fd..3578047b 100644
--- a/src/filesystem/FileFinder.php
+++ b/src/filesystem/FileFinder.php
@@ -1,365 +1,365 @@
<?php
/**
* Find files on disk matching criteria, like the 'find' system utility. Use of
* this class is straightforward:
*
* // Find PHP files in /tmp
* $files = id(new FileFinder('/tmp'))
* ->withType('f')
* ->withSuffix('php')
* ->find();
*
* @task create Creating a File Query
* @task config Configuring File Queries
* @task exec Executing the File Query
* @task internal Internal
*/
final class FileFinder extends Phobject {
private $root;
private $exclude = array();
private $paths = array();
private $name = array();
private $suffix = array();
private $nameGlobs = array();
private $type;
private $generateChecksums = false;
private $followSymlinks;
private $forceMode;
/**
* Create a new FileFinder.
*
* @param string $root Root directory to find files beneath.
- * @return this
+ * @return $this
* @task create
*/
public function __construct($root) {
$this->root = rtrim($root, '/');
}
/**
* @task config
*/
public function excludePath($path) {
$this->exclude[] = $path;
return $this;
}
/**
* @task config
*/
public function withName($name) {
$this->name[] = $name;
return $this;
}
/**
* @task config
*/
public function withSuffix($suffix) {
$this->suffix[] = $suffix;
return $this;
}
/**
* @task config
*/
public function withPath($path) {
$this->paths[] = $path;
return $this;
}
/**
* @task config
*/
public function withType($type) {
$this->type = $type;
return $this;
}
/**
* @task config
*/
public function withFollowSymlinks($follow) {
$this->followSymlinks = $follow;
return $this;
}
/**
* @task config
*/
public function setGenerateChecksums($generate) {
$this->generateChecksums = $generate;
return $this;
}
public function getGenerateChecksums() {
return $this->generateChecksums;
}
public function withNameGlob($pattern) {
$this->nameGlobs[] = $pattern;
return $this;
}
/**
* @task config
* @param string $mode Either "php", "shell", or the empty string.
*/
public function setForceMode($mode) {
$this->forceMode = $mode;
return $this;
}
/**
* @task internal
*/
public function validateFile($file) {
if ($this->name) {
$matches = false;
foreach ($this->name as $curr_name) {
if (basename($file) === $curr_name) {
$matches = true;
break;
}
}
if (!$matches) {
return false;
}
}
if ($this->nameGlobs) {
$name = basename($file);
$matches = false;
foreach ($this->nameGlobs as $glob) {
$glob = addcslashes($glob, '\\');
if (fnmatch($glob, $name)) {
$matches = true;
break;
}
}
if (!$matches) {
return false;
}
}
if ($this->suffix) {
$matches = false;
foreach ($this->suffix as $suffix) {
$suffix = addcslashes($suffix, '\\?*');
$suffix = '*.'.$suffix;
if (fnmatch($suffix, $file)) {
$matches = true;
break;
}
}
if (!$matches) {
return false;
}
}
if ($this->paths) {
$matches = false;
foreach ($this->paths as $path) {
if (fnmatch($path, $this->root.'/'.$file)) {
$matches = true;
break;
}
}
if (!$matches) {
return false;
}
}
$fullpath = $this->root.'/'.ltrim($file, '/');
if (($this->type == 'f' && is_dir($fullpath))
|| ($this->type == 'd' && !is_dir($fullpath))) {
return false;
}
return true;
}
/**
* @task internal
*/
private function getFiles($dir) {
$found = Filesystem::listDirectory($this->root.'/'.$dir, true);
$files = array();
if (strlen($dir) > 0) {
$dir = rtrim($dir, '/').'/';
}
foreach ($found as $filename) {
// Only exclude files whose names match relative to the root.
if ($dir == '') {
$matches = true;
foreach ($this->exclude as $exclude_path) {
if (fnmatch(ltrim($exclude_path, './'), $dir.$filename)) {
$matches = false;
break;
}
}
if (!$matches) {
continue;
}
}
if ($this->validateFile($dir.$filename)) {
$files[] = $dir.$filename;
}
if (is_dir($this->root.'/'.$dir.$filename)) {
foreach ($this->getFiles($dir.$filename) as $file) {
$files[] = $file;
}
}
}
return $files;
}
/**
* @task exec
*/
public function find() {
$files = array();
if (!is_dir($this->root) || !is_readable($this->root)) {
throw new Exception(
pht(
"Invalid %s root directory specified ('%s'). Root directory ".
"must be a directory, be readable, and be specified with an ".
"absolute path.",
__CLASS__,
$this->root));
}
if ($this->forceMode == 'shell') {
$php_mode = false;
} else if ($this->forceMode == 'php') {
$php_mode = true;
} else {
$php_mode = (phutil_is_windows() || !Filesystem::binaryExists('find'));
}
if ($php_mode) {
$files = $this->getFiles('');
} else {
$args = array();
$command = array();
$command[] = 'find';
if ($this->followSymlinks) {
$command[] = '-L';
}
$command[] = '.';
if ($this->exclude) {
$command[] = $this->generateList('path', $this->exclude).' -prune';
$command[] = '-o';
}
if ($this->type) {
$command[] = '-type %s';
$args[] = $this->type;
}
if ($this->name) {
$command[] = $this->generateList('name', $this->name, 'name');
}
if ($this->suffix) {
$command[] = $this->generateList('name', $this->suffix, 'suffix');
}
if ($this->paths) {
$command[] = $this->generateList('path', $this->paths);
}
if ($this->nameGlobs) {
$command[] = $this->generateList('name', $this->nameGlobs);
}
$command[] = '-print0';
array_unshift($args, implode(' ', $command));
list($stdout) = newv('ExecFuture', $args)
->setCWD($this->root)
->resolvex();
$stdout = trim($stdout);
if (!strlen($stdout)) {
return array();
}
$files = explode("\0", $stdout);
// On OSX/BSD, find prepends a './' to each file.
foreach ($files as $key => $file) {
// When matching directories, we can get "." back in the result set,
// but this isn't an interesting result.
if ($file == '.') {
unset($files[$key]);
continue;
}
if (substr($files[$key], 0, 2) == './') {
$files[$key] = substr($files[$key], 2);
}
}
}
if (!$this->generateChecksums) {
return $files;
} else {
$map = array();
foreach ($files as $line) {
$fullpath = $this->root.'/'.ltrim($line, '/');
if (is_dir($fullpath)) {
$map[$line] = null;
} else {
$map[$line] = md5_file($fullpath);
}
}
return $map;
}
}
/**
* @task internal
*/
private function generateList(
$flag,
array $items,
$mode = 'glob') {
foreach ($items as $key => $item) {
// If the mode is not "glob" mode, we're going to escape glob characters
// in the pattern. Otherwise, we escape only backslashes.
if ($mode === 'glob') {
$item = addcslashes($item, '\\');
} else {
$item = addcslashes($item, '\\*?');
}
if ($mode === 'suffix') {
$item = '*.'.$item;
}
$item = (string)csprintf('%s %s', '-'.$flag, $item);
$items[$key] = $item;
}
$items = implode(' -o ', $items);
return '"(" '.$items.' ")"';
}
}
diff --git a/src/filesystem/FileList.php b/src/filesystem/FileList.php
index 89eb6f7d..e17a4552 100644
--- a/src/filesystem/FileList.php
+++ b/src/filesystem/FileList.php
@@ -1,93 +1,93 @@
<?php
/**
* A list of files, primarily useful for parsing command line arguments. This
* class makes it easier to deal with user-specified lists of files and
* directories used by command line tools.
*
* $list = new FileList(array_slice($argv, 1));
* foreach ($some_files as $file) {
* if ($list->contains($file)) {
* do_something_to_this($file);
* }
* }
*
* This sort of construction will allow the user to type "src" in order
* to indicate 'all relevant files underneath "src/"'.
*
* @task create Creating a File List
* @task test Testing File Lists
*/
final class FileList extends Phobject {
private $files = array();
private $dirs = array();
/**
* Build a new FileList from an array of paths, e.g. from $argv.
*
* @param list $paths List of relative or absolute file paths.
- * @return this
+ * @return $this
* @task create
*/
public function __construct($paths) {
foreach ($paths as $path) {
$path = Filesystem::resolvePath($path);
if (is_dir($path)) {
$path = rtrim($path, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$this->dirs[$path] = true;
}
$this->files[] = $path;
}
}
/**
* Determine if a path is one of the paths in the list. Note that an empty
* file list is considered to contain every file.
*
* @param string $path Relative or absolute system file path.
* @param bool $allow_parent_directory (optional) If true, consider the
* path to be contained in the list if the list contains a
* parent directory. If false, require that the path be part
* of the list explicitly.
* @return bool If true, the file is in the list.
* @task test
*/
public function contains($path, $allow_parent_directory = true) {
if ($this->isEmpty()) {
return true;
}
$path = Filesystem::resolvePath($path);
if (is_dir($path)) {
$path .= DIRECTORY_SEPARATOR;
}
foreach ($this->files as $file) {
if ($file == $path) {
return true;
}
if ($allow_parent_directory) {
$len = strlen($file);
if (isset($this->dirs[$file]) && !strncmp($file, $path, $len)) {
return true;
}
}
}
return false;
}
/**
* Check if the file list is empty -- that is, it contains no files.
*
* @return bool If true, the list is empty.
* @task test
*/
public function isEmpty() {
return !$this->files;
}
}
diff --git a/src/filesystem/PhutilDeferredLog.php b/src/filesystem/PhutilDeferredLog.php
index e2d1ac35..78729ac1 100644
--- a/src/filesystem/PhutilDeferredLog.php
+++ b/src/filesystem/PhutilDeferredLog.php
@@ -1,247 +1,247 @@
<?php
/**
* Object that writes to a logfile when it is destroyed. This allows you to add
* more data to the log as execution unfolds, while still ensuring a write in
* normal circumstances (see below for discussion of cases where writes may not
* occur).
*
* Create the object with a logfile and format:
*
* $log = new PhutilDeferredLog('/path/to/access.log', "[%T]\t%u");
*
* Update the log with information as it becomes available:
*
* $log->setData(
* array(
* 'T' => date('c'),
* 'u' => $username,
* ));
*
* The log will be appended when the object's destructor is called, or when you
* invoke @{method:write}. Note that programs can exit without invoking object
* destructors (e.g., in the case of an unhandled exception, memory exhaustion,
* or SIGKILL) so writes are not guaranteed. You can call @{method:write} to
* force an explicit write to disk before the destructor is called.
*
* Log variables will be written with bytes 0x00-0x1F, 0x7F-0xFF, and backslash
* escaped using C-style escaping. Since this range includes tab, you can use
* tabs as field separators to ensure the file format is easily parsable. In
* PHP, you can decode this encoding with `stripcslashes`.
*
* If a variable is included in the log format but a value is never provided
* with @{method:setData}, it will be written as "-".
*
* @task log Logging
* @task write Writing the Log
* @task internal Internals
*/
final class PhutilDeferredLog extends Phobject {
private $file;
private $format;
private $data;
private $didWrite;
private $failQuietly;
/* -( Logging )------------------------------------------------------------ */
/**
* Create a new log entry, which will be written later. The format string
* should use "%x"-style placeholders to represent data which will be added
* later:
*
* $log = new PhutilDeferredLog('/some/file.log', '[%T] %u');
*
* @param string|null $file The file the entry should be written to, or null
* to create a log object which does not write anywhere.
* @param string $format The log entry format.
* @task log
*/
public function __construct($file, $format) {
$this->file = $file;
$this->format = $format;
$this->data = array();
$this->didWrite = false;
}
/**
* Add data to the log. Provide a map of variables to replace in the format
* string. For example, if you use a format string like:
*
* "[%T]\t%u"
*
* ...you might add data like this:
*
* $log->setData(
* array(
* 'T' => date('c'),
* 'u' => $username,
* ));
*
* When the log is written, the "%T" and "%u" variables will be replaced with
* the values you provide.
*
* @param dict $map Map of variables to values.
- * @return this
+ * @return $this
* @task log
*/
public function setData(array $map) {
$this->data = $map + $this->data;
return $this;
}
/**
* Get existing log data.
*
* @param string $key Log data key.
* @param wild $default (optional) Default to return if data does not
* exist.
* @return wild Data, or default if data does not exist.
* @task log
*/
public function getData($key, $default = null) {
return idx($this->data, $key, $default);
}
/**
* Set the path where the log will be written. You can pass `null` to prevent
* the log from writing.
*
* NOTE: You can not change the file after the log writes.
*
* @param string|null $file File where the entry should be written to, or
* null to prevent writes.
- * @return this
+ * @return $this
* @task log
*/
public function setFile($file) {
if ($this->didWrite) {
throw new Exception(
pht('You can not change the logfile after a write has occurred!'));
}
$this->file = $file;
return $this;
}
public function getFile() {
return $this->file;
}
/**
* Set quiet (logged) failure, instead of the default loud (exception)
* failure. Throwing exceptions from destructors which exit at the end of a
* request can result in difficult-to-debug behavior.
*/
public function setFailQuietly($fail_quietly) {
$this->failQuietly = $fail_quietly;
return $this;
}
/* -( Writing the Log )---------------------------------------------------- */
/**
* When the log object is destroyed, it writes if it hasn't written yet.
* @task write
*/
public function __destruct() {
$this->write();
}
/**
* Write the log explicitly, if it hasn't been written yet. Normally you do
* not need to call this method; it will be called when the log object is
* destroyed. However, you can explicitly force the write earlier by calling
* this method.
*
* A log object will never write more than once, so it is safe to call this
* method even if the object's destructor later runs.
*
- * @return this
+ * @return $this
* @task write
*/
public function write() {
if ($this->didWrite) {
return $this;
}
// Even if we aren't going to write, format the line to catch any errors
// and invoke possible __toString() calls.
$line = $this->format();
try {
if ($this->file !== null) {
$dir = dirname($this->file);
if (!Filesystem::pathExists($dir)) {
Filesystem::createDirectory($dir, 0755, true);
}
$ok = @file_put_contents(
$this->file,
$line,
FILE_APPEND | LOCK_EX);
if ($ok === false) {
throw new Exception(
pht(
'Unable to write to logfile "%s"!',
$this->file));
}
}
} catch (Exception $ex) {
if ($this->failQuietly) {
phlog($ex);
} else {
throw $ex;
}
}
$this->didWrite = true;
return $this;
}
/* -( Internals )---------------------------------------------------------- */
/**
* Format the log string, replacing "%x" variables with values.
*
* @return string Finalized, log string for writing to disk.
* @task internals
*/
private function format() {
// Always convert '%%' to literal '%'.
$map = array('%' => '%') + $this->data;
$result = '';
$saw_percent = false;
foreach (phutil_utf8v($this->format) as $c) {
if ($saw_percent) {
$saw_percent = false;
if (array_key_exists($c, $map)) {
$result .= phutil_encode_log($map[$c]);
} else {
$result .= '-';
}
} else if ($c == '%') {
$saw_percent = true;
} else {
$result .= $c;
}
}
return rtrim($result)."\n";
}
}
diff --git a/src/filesystem/PhutilLock.php b/src/filesystem/PhutilLock.php
index 3be495e1..86b74997 100644
--- a/src/filesystem/PhutilLock.php
+++ b/src/filesystem/PhutilLock.php
@@ -1,235 +1,235 @@
<?php
/**
* Base class for locks, like file locks.
*
* libphutil provides a concrete lock in @{class:PhutilFileLock}.
*
* $lock->lock();
* do_contentious_things();
* $lock->unlock();
*
* If the lock can't be acquired because it is already held,
* @{class:PhutilLockException} is thrown. Other exceptions indicate
* permanent failure unrelated to locking.
*
* When extending this class, you should call @{method:getLock} to look up
* an existing lock object, and @{method:registerLock} when objects are
* constructed to register for automatic unlock on shutdown.
*
* @task impl Lock Implementation
* @task registry Lock Registry
* @task construct Constructing Locks
* @task status Determining Lock Status
* @task lock Locking
* @task internal Internals
*/
abstract class PhutilLock extends Phobject {
private static $registeredShutdownFunction = false;
private static $locks = array();
private $locked = false;
private $profilerID;
private $name;
/* -( Constructing Locks )------------------------------------------------- */
/**
* Build a new lock, given a lock name. The name should be globally unique
* across all locks.
*
* @param string $name Globally unique lock name.
* @task construct
*/
protected function __construct($name) {
$this->name = $name;
}
/* -( Lock Implementation )------------------------------------------------ */
/**
* Acquires the lock, or throws @{class:PhutilLockException} if it fails.
*
* @param float $wait Seconds to block waiting for the lock.
* @return void
* @task impl
*/
abstract protected function doLock($wait);
/**
* Releases the lock.
*
* @return void
* @task impl
*/
abstract protected function doUnlock();
/* -( Lock Registry )------------------------------------------------------ */
/**
* Returns a globally unique name for this lock.
*
* @return string Globally unique lock name, across all locks.
* @task registry
*/
final public function getName() {
return $this->name;
}
/**
* Get a named lock, if it has been registered.
*
* @param string $name Lock name.
* @task registry
*/
protected static function getLock($name) {
return idx(self::$locks, $name);
}
/**
* Register a lock for cleanup when the process exits.
*
* @param PhutilLock $lock Lock to register.
* @task registry
*/
protected static function registerLock(PhutilLock $lock) {
if (!self::$registeredShutdownFunction) {
register_shutdown_function(array(__CLASS__, 'unlockAll'));
self::$registeredShutdownFunction = true;
}
$name = $lock->getName();
if (self::getLock($name)) {
throw new Exception(
pht("Lock '%s' is already registered!", $name));
}
self::$locks[$name] = $lock;
}
/* -( Determining Lock Status )-------------------------------------------- */
/**
* Determine if the lock is currently held.
*
* @return bool True if the lock is held.
*
* @task status
*/
final public function isLocked() {
return $this->locked;
}
/* -( Locking )------------------------------------------------------------ */
/**
* Acquire the lock. If lock acquisition fails because the lock is held by
* another process, throws @{class:PhutilLockException}. Other exceptions
* indicate that lock acquisition has failed for reasons unrelated to locking.
*
* If the lock is already held by this process, this method throws. You can
* test the lock status with @{method:isLocked}.
*
* @param float $wait (optional) Seconds to block waiting for the lock. By
* default, do not block.
- * @return this
+ * @return $this
*
* @task lock
*/
final public function lock($wait = 0) {
if ($this->locked) {
$name = $this->getName();
throw new Exception(
pht("Lock '%s' has already been locked by this process.", $name));
}
$profiler = PhutilServiceProfiler::getInstance();
$profiler_id = $profiler->beginServiceCall(
array(
'type' => 'lock',
'name' => $this->getName(),
));
try {
$this->doLock((float)$wait);
} catch (Exception $ex) {
$profiler->endServiceCall(
$profiler_id,
array(
'lock' => false,
));
throw $ex;
}
$this->profilerID = $profiler_id;
$this->locked = true;
return $this;
}
/**
* Release the lock. Throws an exception on failure, e.g. if the lock is not
* currently held.
*
- * @return this
+ * @return $this
*
* @task lock
*/
final public function unlock() {
if (!$this->locked) {
$name = $this->getName();
throw new Exception(
pht("Lock '%s' is not locked by this process!", $name));
}
$this->doUnlock();
$profiler = PhutilServiceProfiler::getInstance();
$profiler->endServiceCall(
$this->profilerID,
array(
'lock' => true,
));
$this->profilerID = null;
$this->locked = false;
return $this;
}
/* -( Internals )---------------------------------------------------------- */
/**
* On shutdown, we release all the locks. You should not call this method
* directly. Use @{method:unlock} to release individual locks.
*
* @return void
*
* @task internal
*/
public static function unlockAll() {
foreach (self::$locks as $key => $lock) {
if ($lock->locked) {
$lock->unlock();
}
}
}
}
diff --git a/src/filesystem/TempFile.php b/src/filesystem/TempFile.php
index ec493a7f..0596ed3c 100644
--- a/src/filesystem/TempFile.php
+++ b/src/filesystem/TempFile.php
@@ -1,118 +1,118 @@
<?php
/**
* Simple wrapper to create a temporary file and guarantee it will be deleted on
* object destruction. Used like a string to path:
*
* $temp = new TempFile();
* Filesystem::writeFile($temp, 'Hello World');
* echo "Wrote data to path: ".$temp;
*
* Throws Filesystem exceptions for errors.
*
* @task create Creating a Temporary File
* @task config Configuration
* @task internal Internals
*/
final class TempFile extends Phobject {
private $dir;
private $file;
private $preserve;
private $destroyed = false;
/* -( Creating a Temporary File )------------------------------------------ */
/**
* Create a new temporary file.
*
* @param string $filename (optional) Filename hint. This is useful if you
* intend to edit the file with an interactive editor, so the
* user's editor shows "commit-message" instead of
* "p3810hf-1z9b89bas".
* @param string $root_directory (optional) Root directory to hold the file.
* If omitted, the system temporary directory (often "/tmp")
* will be used by default.
* @task create
*/
public function __construct($filename = null, $root_directory = null) {
$this->dir = Filesystem::createTemporaryDirectory(
'',
0700,
$root_directory);
if ($filename === null) {
$this->file = tempnam($this->dir, getmypid().'-');
} else {
$this->file = $this->dir.'/'.$filename;
}
// If we fatal (e.g., call a method on NULL), destructors are not called.
// Make sure our destructor is invoked.
register_shutdown_function(array($this, '__destruct'));
Filesystem::writeFile($this, '');
}
/* -( Configuration )------------------------------------------------------ */
/**
* Normally, the file is deleted when this object passes out of scope. You
* can set it to be preserved instead.
*
* @param bool $preserve True to preserve the file after object destruction.
- * @return this
+ * @return $this
* @task config
*/
public function setPreserveFile($preserve) {
$this->preserve = $preserve;
return $this;
}
/* -( Internals )---------------------------------------------------------- */
/**
* Get the path to the temporary file. Normally you can just use the object
* in a string context.
*
* @return string Absolute path to the temporary file.
* @task internal
*/
public function __toString() {
return $this->file;
}
/**
* When the object is destroyed, it destroys the temporary file. You can
* change this behavior with @{method:setPreserveFile}.
*
* @task internal
*/
public function __destruct() {
if ($this->destroyed) {
return;
}
if ($this->preserve) {
return;
}
Filesystem::remove($this->dir);
// NOTE: tempnam() doesn't guarantee it will return a file inside the
// directory you passed to the function, so we make sure to nuke the file
// explicitly.
Filesystem::remove($this->file);
$this->file = null;
$this->dir = null;
$this->destroyed = true;
}
}
diff --git a/src/filesystem/linesofalarge/LinesOfALarge.php b/src/filesystem/linesofalarge/LinesOfALarge.php
index 4b030bd9..e4a5fa3a 100644
--- a/src/filesystem/linesofalarge/LinesOfALarge.php
+++ b/src/filesystem/linesofalarge/LinesOfALarge.php
@@ -1,224 +1,224 @@
<?php
/**
* Abstraction for processing large inputs without holding them in memory. This
* class implements line-oriented, buffered reads of some external stream, where
* a "line" is characterized by some delimiter character. This provides a
* straightforward interface for most large-input tasks, with relatively good
* performance.
*
* If your stream is not large, it is generally more efficient (and certainly
* simpler) to read the entire stream first and then process it (e.g., with
* `explode()`).
*
* This class is abstract. The concrete implementations available are:
*
* - @{class:LinesOfALargeFile}, for reading large files; and
* - @{class:LinesOfALargeExecFuture}, for reading large output from
* subprocesses.
*
* For example:
*
* foreach (new LinesOfALargeFile('/path/to/file.log') as $line) {
* // ...
* }
*
* By default, a line is delimited by "\n". The delimiting character is
* not returned. You can change the character with @{method:setDelimiter}. The
* last part of the file is returned as the last $line, even if it does not
* include a terminating character (if it does, the terminating character is
* stripped).
*
* @task config Configuration
* @task internals Internals
* @task iterator Iterator Interface
*/
abstract class LinesOfALarge extends Phobject implements Iterator {
private $pos;
private $buf;
private $num;
private $line;
private $valid;
private $eof;
private $delimiter = "\n";
/* -( Configuration )------------------------------------------------------ */
/**
* Change the "line" delimiter character, which defaults to "\n". This is
* used to determine where each line ends.
*
* If you pass `null`, data will be read from source as it becomes available,
* without looking for delimiters. You can use this to stream a large file or
* the output of a command which returns a large amount of data.
*
* @param string|null $character A one-byte delimiter character.
- * @return this
+ * @return $this
* @task config
*/
final public function setDelimiter($character) {
if (($character !== null) && (strlen($character) !== 1)) {
throw new Exception(
pht('Delimiter character must be one byte in length or null.'));
}
$this->delimiter = $character;
return $this;
}
/* -( Internals )---------------------------------------------------------- */
/**
* Hook, called before @{method:rewind()}. Allows a concrete implementation
* to open resources or reset state.
*
* @return void
* @task internals
*/
abstract protected function willRewind();
/**
* Called when the iterator needs more data. The subclass should return more
* data, or empty string to indicate end-of-stream.
*
* @return string Data, or empty string for end-of-stream.
* @task internals
*/
abstract protected function readMore();
/* -( Iterator Interface )------------------------------------------------- */
/**
* @task iterator
*/
final public function rewind() {
$this->willRewind();
$this->buf = '';
$this->pos = 0;
$this->num = 0;
$this->eof = false;
$this->valid = true;
$this->next();
}
/**
* @task iterator
*/
final public function key() {
return $this->num;
}
/**
* @task iterator
*/
final public function current() {
return $this->line;
}
/**
* @task iterator
*/
final public function valid() {
return $this->valid;
}
/**
* @task iterator
*/
final public function next() {
// Consume the stream a chunk at a time into an internal buffer, then
// read lines out of that buffer. This gives us flexibility (stream sources
// only need to be able to read blocks of bytes) and performance (we can
// read in reasonably-sized chunks of many lines), at the cost of some
// complexity in buffer management.
// We do this in a loop to avoid recursion when consuming more bytes, in
// case the size of a line is very large compared to the chunk size we
// read.
while (true) {
if (strlen($this->buf)) {
// If we don't have a delimiter, return the entire buffer.
if ($this->delimiter === null) {
$this->num++;
$this->line = substr($this->buf, $this->pos);
$this->buf = '';
$this->pos = 0;
return;
}
// If we already have some data buffered, try to get the next line from
// the buffer. Search through the buffer for a delimiter. This should be
// the common case.
$endl = strpos($this->buf, $this->delimiter, $this->pos);
if ($endl !== false) {
// We found a delimiter, so return the line it delimits. We leave
// the buffer as-is so we don't need to reallocate it, in case it is
// large relative to the size of a line. Instead, we move our cursor
// within the buffer forward.
$this->num++;
$this->line = substr($this->buf, $this->pos, ($endl - $this->pos));
$this->pos = $endl + 1;
return;
}
// We only have part of a line left in the buffer (no delimiter in the
// remaining piece), so throw away the part we've already emitted and
// continue below.
$this->buf = substr($this->buf, $this->pos);
$this->pos = 0;
}
// We weren't able to produce the next line from the bytes we already had
// buffered, so read more bytes from the input stream.
if ($this->eof) {
// NOTE: We keep track of EOF (an empty read) so we don't make any more
// reads afterward. Normally, we'll return from the first EOF read,
// emit the line, and then next() will be called again. Without tracking
// EOF, we'll attempt another read. A well-behaved implementation should
// still return empty string, but we can protect against any issues
// here by keeping a flag.
$more = '';
} else {
$more = $this->readMore();
}
if (strlen($more)) {
// We got some bytes, so add them to the buffer and then try again.
$this->buf .= $more;
continue;
} else {
// No more bytes. If we have a buffer, return its contents. We
// potentially return part of a line here if the last line had no
// delimiter, but that currently seems reasonable as a default
// behavior. If we don't have a buffer, we're done.
$this->eof = true;
if (strlen($this->buf)) {
$this->num++;
$this->line = $this->buf;
$this->buf = '';
} else {
$this->valid = false;
}
break;
}
}
}
}
diff --git a/src/future/FutureIterator.php b/src/future/FutureIterator.php
index 593d4942..d2ef715e 100644
--- a/src/future/FutureIterator.php
+++ b/src/future/FutureIterator.php
@@ -1,470 +1,470 @@
<?php
/**
* FutureIterator aggregates @{class:Future}s and allows you to respond to them
* in the order they resolve. This is useful because it minimizes the amount of
* time your program spends waiting on parallel processes.
*
* $futures = array(
* 'a.txt' => new ExecFuture('wc -c a.txt'),
* 'b.txt' => new ExecFuture('wc -c b.txt'),
* 'c.txt' => new ExecFuture('wc -c c.txt'),
* );
*
* foreach (new FutureIterator($futures) as $key => $future) {
* // IMPORTANT: keys are preserved but the order of elements is not. This
* // construct iterates over the futures in the order they resolve, so the
* // fastest future is the one you'll get first. This allows you to start
* // doing followup processing as soon as possible.
*
* list($err, $stdout) = $future->resolve();
* do_some_processing($stdout);
* }
*
* For a general overview of futures, see @{article:Using Futures}.
*
* @task basics Basics
* @task config Configuring Iteration
* @task iterator Iterator Interface
* @task internal Internals
*/
final class FutureIterator
extends Phobject
implements Iterator {
private $hold = array();
private $wait = array();
private $work = array();
private $futures = array();
private $key;
private $limit;
private $timeout;
private $isTimeout = false;
private $hasRewound = false;
/* -( Basics )------------------------------------------------------------- */
/**
* Create a new iterator over a list of futures.
*
* @param list $futures List of @{class:Future}s to resolve.
* @task basics
*/
public function __construct(array $futures) {
assert_instances_of($futures, 'Future');
foreach ($futures as $map_key => $future) {
$future->setFutureKey($map_key);
$this->addFuture($future);
}
}
/**
* Block until all futures resolve.
*
* @return void
* @task basics
*/
public function resolveAll() {
// If a caller breaks out of a "foreach" and then calls "resolveAll()",
// interpret it to mean that we should iterate over whatever futures
// remain.
if ($this->hasRewound) {
while ($this->valid()) {
$this->next();
}
} else {
iterator_to_array($this);
}
}
/**
* Add another future to the set of futures. This is useful if you have a
* set of futures to run mostly in parallel, but some futures depend on
* others.
*
* @param Future $future @{class:Future} to add to iterator
* @task basics
*/
public function addFuture(Future $future) {
$key = $future->getFutureKey();
if (isset($this->futures[$key])) {
throw new Exception(
pht(
'This future graph already has a future with key "%s". Each '.
'future must have a unique key.',
$key));
}
$this->futures[$key] = $future;
$this->hold[$key] = $key;
return $this;
}
/* -( Configuring Iteration )---------------------------------------------- */
/**
* Set a maximum amount of time you want to wait before the iterator will
* yield a result. If no future has resolved yet, the iterator will yield
* null for key and value. Among other potential uses, you can use this to
* show some busy indicator:
*
* $futures = id(new FutureIterator($futures))
* ->setUpdateInterval(1);
* foreach ($futures as $future) {
* if ($future === null) {
* echo "Still working...\n";
* } else {
* // ...
* }
* }
*
* This will echo "Still working..." once per second as long as futures are
* resolving. By default, FutureIterator never yields null.
*
* @param float $interval Maximum number of seconds to block waiting on
* futures before yielding null.
- * @return this
+ * @return $this
*
* @task config
*/
public function setUpdateInterval($interval) {
$this->timeout = $interval;
return $this;
}
/**
* Limit the number of simultaneously executing futures.
*
* $futures = id(new FutureIterator($futures))
* ->limit(4);
* foreach ($futures as $future) {
* // Run no more than 4 futures simultaneously.
* }
*
* @param int $max Maximum number of simultaneous jobs allowed.
- * @return this
+ * @return $this
*
* @task config
*/
public function limit($max) {
$this->limit = $max;
return $this;
}
public function setMaximumWorkingSetSize($limit) {
$this->limit = $limit;
return $this;
}
public function getMaximumWorkingSetSize() {
return $this->limit;
}
/* -( Iterator Interface )------------------------------------------------- */
/**
* @task iterator
*/
public function rewind() {
if ($this->hasRewound) {
throw new Exception(
pht('Future graphs can not be rewound.'));
}
$this->hasRewound = true;
$this->next();
}
/**
* @task iterator
*/
public function next() {
// See T13572. If we previously resolved and returned a Future, release
// it now. This prevents us from holding Futures indefinitely when callers
// like FuturePool build long-lived iterators and keep adding new Futures
// to them.
if ($this->key !== null) {
unset($this->futures[$this->key]);
$this->key = null;
}
$this->updateWorkingSet();
if (!$this->work) {
return;
}
$start = microtime(true);
$timeout = $this->timeout;
$this->isTimeout = false;
$working_set = array_select_keys($this->futures, $this->work);
while (true) {
// Update every future first. This is a no-op on futures which have
// already resolved or failed, but we want to give futures an
// opportunity to make progress even if we can resolve something.
foreach ($working_set as $future_key => $future) {
$future->updateFuture();
}
// Check if any future has resolved or failed. If we have any such
// futures, we'll return the first one from the iterator.
$resolve_key = null;
foreach ($working_set as $future_key => $future) {
if ($future->canResolve()) {
$resolve_key = $future_key;
break;
}
}
// We've found a future to resolve, so we're done here for now.
if ($resolve_key !== null) {
$this->moveFutureToDone($resolve_key);
return;
}
// We don't have any futures to resolve yet. Check if we're reached
// an update interval.
$wait_time = 1;
if ($timeout !== null) {
$elapsed = microtime(true) - $start;
if ($elapsed > $timeout) {
$this->isTimeout = true;
return;
}
$wait_time = min($wait_time, $timeout - $elapsed);
}
// We're going to wait. If possible, we'd like to wait with sockets.
// If we can't, we'll just sleep.
$read_sockets = array();
$write_sockets = array();
foreach ($working_set as $future_key => $future) {
$sockets = $future->getReadSockets();
foreach ($sockets as $socket) {
$read_sockets[] = $socket;
}
$sockets = $future->getWriteSockets();
foreach ($sockets as $socket) {
$write_sockets[] = $socket;
}
}
$use_sockets = ($read_sockets || $write_sockets);
if ($use_sockets) {
foreach ($working_set as $future) {
$wait_time = min($wait_time, $future->getDefaultWait());
}
$this->waitForSockets($read_sockets, $write_sockets, $wait_time);
} else {
usleep(1000);
}
}
}
/**
* @task iterator
*/
public function current() {
if ($this->isTimeout) {
return null;
}
return $this->futures[$this->key];
}
/**
* @task iterator
*/
public function key() {
if ($this->isTimeout) {
return null;
}
return $this->key;
}
/**
* @task iterator
*/
public function valid() {
if ($this->isTimeout) {
return true;
}
return ($this->key !== null);
}
/* -( Internals )---------------------------------------------------------- */
/**
* @task internal
*/
protected function updateWorkingSet() {
$limit = $this->getMaximumWorkingSetSize();
$work_count = count($this->work);
// If we're already working on the maximum number of futures, we just have
// to wait for something to resolve. There's no benefit to updating the
// queue since we can never make any meaningful progress.
if ($limit) {
if ($work_count >= $limit) {
return;
}
}
// If any futures that are currently held are no longer blocked by
// dependencies, move them from "hold" to "wait".
foreach ($this->hold as $future_key) {
if (!$this->canMoveFutureToWait($future_key)) {
continue;
}
$this->moveFutureToWait($future_key);
}
$wait_count = count($this->wait);
$hold_count = count($this->hold);
if (!$work_count && !$wait_count && $hold_count) {
throw new Exception(
pht(
'Future graph is stalled: some futures are held, but no futures '.
'are waiting or working. The graph can never resolve.'));
}
// Figure out how many futures we can start. If we don't have a limit,
// we can start every waiting future. If we do have a limit, we can only
// start as many futures as we have slots for.
if ($limit) {
$work_limit = min($limit, $wait_count);
} else {
$work_limit = $wait_count;
}
// If we're ready to start futures, start them now.
if ($work_limit) {
foreach ($this->wait as $future_key) {
$this->moveFutureToWork($future_key);
$work_limit--;
if (!$work_limit) {
return;
}
}
}
}
private function canMoveFutureToWait($future_key) {
return true;
}
private function moveFutureToWait($future_key) {
unset($this->hold[$future_key]);
$this->wait[$future_key] = $future_key;
}
private function moveFutureToWork($future_key) {
unset($this->wait[$future_key]);
$this->work[$future_key] = $future_key;
$future = $this->futures[$future_key];
if (!$future->getHasFutureStarted()) {
$future
->setRaiseExceptionOnStart(false)
->start();
}
}
private function moveFutureToDone($future_key) {
$this->key = $future_key;
unset($this->work[$future_key]);
// Before we return, do another working set update so we start any
// futures that are ready to go as soon as we can.
$this->updateWorkingSet();
}
/**
* Wait for activity on one of several sockets.
*
* @param list $read_list List of sockets expected to become readable.
* @param list $write_list List of sockets expected to become writable.
* @param float $timeout (optional) Timeout, in seconds.
* @return void
*/
private function waitForSockets(
array $read_list,
array $write_list,
$timeout = 1.0) {
static $handler_installed = false;
if (!$handler_installed) {
// If we're spawning child processes, we need to install a signal handler
// here to catch cases like execing '(sleep 60 &) &' where the child
// exits but a socket is kept open. But we don't actually need to do
// anything because the SIGCHLD will interrupt the stream_select(), as
// long as we have a handler registered.
if (function_exists('pcntl_signal')) {
if (!pcntl_signal(SIGCHLD, array(__CLASS__, 'handleSIGCHLD'))) {
throw new Exception(pht('Failed to install signal handler!'));
}
}
$handler_installed = true;
}
$timeout_sec = (int)$timeout;
$timeout_usec = (int)(1000000 * ($timeout - $timeout_sec));
$exceptfds = array();
$ok = @stream_select(
$read_list,
$write_list,
$exceptfds,
$timeout_sec,
$timeout_usec);
if ($ok === false) {
// Hopefully, means we received a SIGCHLD. In the worst case, we degrade
// to a busy wait.
}
}
public static function handleSIGCHLD($signo) {
// This function is a dummy, we just need to have some handler registered
// so that PHP will get interrupted during "stream_select()". If we don't
// register a handler, "stream_select()" won't fail.
}
}
diff --git a/src/future/exec/ExecFuture.php b/src/future/exec/ExecFuture.php
index 5c25fc97..056dc8e4 100644
--- a/src/future/exec/ExecFuture.php
+++ b/src/future/exec/ExecFuture.php
@@ -1,1051 +1,1051 @@
<?php
/**
* Execute system commands in parallel using futures.
*
* ExecFuture is a future, which means it runs asynchronously and represents
* a value which may not exist yet. See @{article:Using Futures} for an
* explanation of futures. When an ExecFuture resolves, it returns the exit
* code, stdout and stderr of the process it executed.
*
* ExecFuture is the core command execution implementation in libphutil, but is
* exposed through a number of APIs. See @{article:Command Execution} for more
* discussion about executing system commands.
*
* @task create Creating ExecFutures
* @task resolve Resolving Execution
* @task config Configuring Execution
* @task info Command Information
* @task interact Interacting With Commands
* @task internal Internals
*/
final class ExecFuture extends PhutilExecutableFuture {
private $pipes = array();
private $proc = null;
private $start = null;
private $procStatus = null;
private $stdout = null;
private $stderr = null;
private $stdin = null;
private $closePipe = true;
private $stdoutPos = 0;
private $stderrPos = 0;
private $readBufferSize;
private $stdoutSizeLimit = PHP_INT_MAX;
private $stderrSizeLimit = PHP_INT_MAX;
private $profilerCallID;
private $killedByTimeout;
private $windowsStdoutTempFile = null;
private $windowsStderrTempFile = null;
private $terminateTimeout;
private $didTerminate;
private $killTimeout;
private static $descriptorSpec = array(
0 => array('pipe', 'r'), // stdin
1 => array('pipe', 'w'), // stdout
2 => array('pipe', 'w'), // stderr
);
protected function didConstruct() {
$this->stdin = new PhutilRope();
}
/* -( Command Information )------------------------------------------------ */
/**
* Retrieve the byte limit for the stderr buffer.
*
* @return int Maximum buffer size, in bytes.
* @task info
*/
public function getStderrSizeLimit() {
return $this->stderrSizeLimit;
}
/**
* Retrieve the byte limit for the stdout buffer.
*
* @return int Maximum buffer size, in bytes.
* @task info
*/
public function getStdoutSizeLimit() {
return $this->stdoutSizeLimit;
}
/**
* Get the process's pid. This only works after execution is initiated, e.g.
* by a call to start().
*
* @return int Process ID of the executing process.
* @task info
*/
public function getPID() {
$status = $this->procGetStatus();
return $status['pid'];
}
public function hasPID() {
if ($this->procStatus) {
return true;
}
if ($this->proc) {
return true;
}
return false;
}
/* -( Configuring Execution )---------------------------------------------- */
/**
* Set a maximum size for the stdout read buffer. To limit stderr, see
* @{method:setStderrSizeLimit}. The major use of these methods is to use less
* memory if you are running a command which sometimes produces huge volumes
* of output that you don't really care about.
*
* NOTE: Setting this to 0 means "no buffer", not "unlimited buffer".
*
* @param int $limit Maximum size of the stdout read buffer.
- * @return this
+ * @return $this
* @task config
*/
public function setStdoutSizeLimit($limit) {
$this->stdoutSizeLimit = $limit;
return $this;
}
/**
* Set a maximum size for the stderr read buffer.
* See @{method:setStdoutSizeLimit} for discussion.
*
* @param int $limit Maximum size of the stderr read buffer.
- * @return this
+ * @return $this
* @task config
*/
public function setStderrSizeLimit($limit) {
$this->stderrSizeLimit = $limit;
return $this;
}
/**
* Set the maximum internal read buffer size this future. The future will
* block reads once the internal stdout or stderr buffer exceeds this size.
*
* NOTE: If you @{method:resolve} a future with a read buffer limit, you may
* block forever!
*
* TODO: We should probably release the read buffer limit during
* @{method:resolve}, or otherwise detect this. For now, be careful.
*
* @param int|null $read_buffer_size Maximum buffer size, or `null` for
* unlimited.
- * @return this
+ * @return $this
*/
public function setReadBufferSize($read_buffer_size) {
$this->readBufferSize = $read_buffer_size;
return $this;
}
/* -( Interacting With Commands )------------------------------------------ */
/**
* Read and return output from stdout and stderr, if any is available. This
* method keeps a read cursor on each stream, but the entire streams are
* still returned when the future resolves. You can call read() again after
* resolving the future to retrieve only the parts of the streams you did not
* previously read:
*
* $future = new ExecFuture('...');
* // ...
* list($stdout) = $future->read(); // Returns output so far
* list($stdout) = $future->read(); // Returns new output since first call
* // ...
* list($stdout) = $future->resolvex(); // Returns ALL output
* list($stdout) = $future->read(); // Returns unread output
*
* NOTE: If you set a limit with @{method:setStdoutSizeLimit} or
* @{method:setStderrSizeLimit}, this method will not be able to read data
* past the limit.
*
* NOTE: If you call @{method:discardBuffers}, all the stdout/stderr data
* will be thrown away and the cursors will be reset.
*
* @return pair <$stdout, $stderr> pair with new output since the last call
* to this method.
* @task interact
*/
public function read() {
$stdout_value = $this->readStdout();
$stderr = $this->stderr;
if ($stderr === null) {
$stderr_value = '';
} else {
$stderr_value = substr($stderr, $this->stderrPos);
}
$result = array(
$stdout_value,
$stderr_value,
);
$this->stderrPos = $this->getStderrBufferLength();
return $result;
}
public function readStdout() {
if ($this->start) {
$this->updateFuture(); // Sync
}
$stdout = $this->stdout;
if ($stdout === null) {
$result = '';
} else {
$result = substr($stdout, $this->stdoutPos);
}
$this->stdoutPos = $this->getStdoutBufferLength();
return $result;
}
/**
* Write data to stdin of the command.
*
* @param string $data Data to write.
* @param bool $keep_pipe (optional) If true, keep the pipe open for writing.
* By default, the pipe will be closed as soon as possible so
* that commands which listen for EOF will execute. If you want
* to keep the pipe open past the start of command execution, do
* an empty write with `$keep_pipe = true` first.
- * @return this
+ * @return $this
* @task interact
*/
public function write($data, $keep_pipe = false) {
if (strlen($data)) {
if (!$this->stdin) {
throw new Exception(pht('Writing to a closed pipe!'));
}
$this->stdin->append($data);
}
$this->closePipe = !$keep_pipe;
return $this;
}
/**
* Permanently discard the stdout and stderr buffers and reset the read
* cursors. This is basically useful only if you are streaming a large amount
* of data from some process.
*
* Conceivably you might also need to do this if you're writing a client using
* @{class:ExecFuture} and `netcat`, but you probably should not do that.
*
* NOTE: This completely discards the data. It won't be available when the
* future resolves. This is almost certainly only useful if you need the
* buffer memory for some reason.
*
- * @return this
+ * @return $this
* @task interact
*/
public function discardBuffers() {
$this->discardStdoutBuffer();
$this->stderr = '';
$this->stderrPos = 0;
return $this;
}
public function discardStdoutBuffer() {
$this->stdout = '';
$this->stdoutPos = 0;
return $this;
}
/**
* Returns true if this future was killed by a timeout configured with
* @{method:setTimeout}.
*
* @return bool True if the future was killed for exceeding its time limit.
*/
public function getWasKilledByTimeout() {
return $this->killedByTimeout;
}
/* -( Configuring Execution )---------------------------------------------- */
/**
* Set a hard limit on execution time. If the command runs longer, it will
* be terminated and the future will resolve with an error code. You can test
* if a future was killed by a timeout with @{method:getWasKilledByTimeout}.
*
* The subprocess will be sent a `TERM` signal, and then a `KILL` signal a
* short while later if it fails to exit.
*
* @param int $seconds Maximum number of seconds this command may execute for
* before it is signaled.
- * @return this
+ * @return $this
* @task config
*/
public function setTimeout($seconds) {
$this->terminateTimeout = $seconds;
$this->killTimeout = $seconds + min($seconds, 60);
return $this;
}
/* -( Resolving Execution )------------------------------------------------ */
/**
* Resolve a command you expect to exit with return code 0. Works like
* @{method:resolve}, but throws if $err is nonempty. Returns only
* $stdout and $stderr. See also @{function:execx}.
*
* list($stdout, $stderr) = $future->resolvex();
*
* @param float Optional timeout after which resolution will pause and
* execution will return to the caller.
* @return pair <$stdout, $stderr> pair.
* @task resolve
*/
public function resolvex() {
$result = $this->resolve();
return $this->raiseResultError($result);
}
/**
* Resolve a command you expect to return valid JSON. Works like
* @{method:resolvex}, but also throws if stderr is nonempty, or stdout is not
* valid JSON. Returns a PHP array, decoded from the JSON command output.
*
* @param float Optional timeout after which resolution will pause and
* execution will return to the caller.
* @return array PHP array, decoded from JSON command output.
* @task resolve
*/
public function resolveJSON() {
list($stdout, $stderr) = $this->resolvex();
if (strlen($stderr)) {
$cmd = $this->getCommand();
throw new CommandException(
pht(
"JSON command '%s' emitted text to stderr when none was expected: %d",
$cmd,
$stderr),
$cmd,
0,
$stdout,
$stderr);
}
try {
return phutil_json_decode($stdout);
} catch (PhutilJSONParserException $ex) {
$cmd = $this->getCommand();
throw new CommandException(
pht(
"JSON command '%s' did not produce a valid JSON object on stdout: %s",
$cmd,
$stdout),
$cmd,
0,
$stdout,
$stderr);
}
}
/**
* Resolve the process by abruptly terminating it.
*
* @return list List of <err, stdout, stderr> results.
* @task resolve
*/
public function resolveKill() {
if (!$this->hasResult()) {
$signal = 9;
if ($this->proc) {
proc_terminate($this->proc, $signal);
}
$this->closeProcess();
$result = array(
128 + $signal,
$this->stdout,
$this->stderr,
);
$this->recordResult($result);
}
return $this->getResult();
}
private function recordResult(array $result) {
$resolve_on_error = $this->getResolveOnError();
if (!$resolve_on_error) {
$result = $this->raiseResultError($result);
}
$this->setResult($result);
}
private function raiseResultError($result) {
list($err, $stdout, $stderr) = $result;
if ($err) {
$cmd = $this->getCommand();
if ($this->getWasKilledByTimeout()) {
// NOTE: The timeout can be a float and PhutilNumber only handles
// integers, so just use "%s" to render it.
$message = pht(
'Command killed by timeout after running for more than %s seconds.',
$this->terminateTimeout);
} else {
$message = pht('Command failed with error #%d!', $err);
}
throw new CommandException(
$message,
$cmd,
$err,
$stdout,
$stderr);
}
return array($stdout, $stderr);
}
/* -( Internals )---------------------------------------------------------- */
/**
* Provides read sockets to the future core.
*
* @return list List of read sockets.
* @task internal
*/
public function getReadSockets() {
list($stdin, $stdout, $stderr) = $this->pipes;
$sockets = array();
if (isset($stdout) && !feof($stdout)) {
$sockets[] = $stdout;
}
if (isset($stderr) && !feof($stderr)) {
$sockets[] = $stderr;
}
return $sockets;
}
/**
* Provides write sockets to the future core.
*
* @return list List of write sockets.
* @task internal
*/
public function getWriteSockets() {
list($stdin, $stdout, $stderr) = $this->pipes;
$sockets = array();
if (isset($stdin) && $this->stdin->getByteLength() && !feof($stdin)) {
$sockets[] = $stdin;
}
return $sockets;
}
/**
* Determine if the read buffer is empty.
*
* @return bool True if the read buffer is empty.
* @task internal
*/
public function isReadBufferEmpty() {
return !$this->getStdoutBufferLength();
}
/**
* Determine if the write buffer is empty.
*
* @return bool True if the write buffer is empty.
* @task internal
*/
public function isWriteBufferEmpty() {
return !$this->getWriteBufferSize();
}
/**
* Determine the number of bytes in the write buffer.
*
* @return int Number of bytes in the write buffer.
* @task internal
*/
public function getWriteBufferSize() {
if (!$this->stdin) {
return 0;
}
return $this->stdin->getByteLength();
}
/**
* Reads some bytes from a stream, discarding output once a certain amount
* has been accumulated.
*
* @param resource $stream Stream to read from.
* @param int $limit Maximum number of bytes to return from $stream. If
* additional bytes are available, they will be read and
* discarded.
* @param string $description Human-readable description of stream, for
* exception message.
* @param int $length Maximum number of bytes to read.
* @return string The data read from the stream.
* @task internal
*/
private function readAndDiscard($stream, $limit, $description, $length) {
$output = '';
if ($length <= 0) {
return '';
}
do {
$data = fread($stream, min($length, 64 * 1024));
if (false === $data) {
throw new Exception(pht('Failed to read from %s', $description));
}
$read_bytes = strlen($data);
if ($read_bytes > 0 && $limit > 0) {
if ($read_bytes > $limit) {
$data = substr($data, 0, $limit);
}
$output .= $data;
$limit -= strlen($data);
}
if (strlen($output) >= $length) {
break;
}
} while ($read_bytes > 0);
return $output;
}
/**
* Begin or continue command execution.
*
* @return bool True if future has resolved.
* @task internal
*/
public function isReady() {
// NOTE: We have a soft dependencies on PhutilErrorTrap here, to avoid
// the need to build it into the Phage agent. Under normal circumstances,
// this class are always available.
if (!$this->pipes) {
$is_windows = phutil_is_windows();
if (!$this->start) {
// We might already have started the timer via initiating resolution.
$this->start = microtime(true);
}
$unmasked_command = $this->getCommand();
$unmasked_command = $unmasked_command->getUnmaskedString();
$pipes = array();
if ($this->hasEnv()) {
$env = $this->getEnv();
} else {
$env = null;
}
$cwd = $this->getCWD();
// NOTE: See note above about Phage.
if (class_exists('PhutilErrorTrap')) {
$trap = new PhutilErrorTrap();
} else {
$trap = null;
}
$spec = self::$descriptorSpec;
if ($is_windows) {
$stdout_file = new TempFile();
$stderr_file = new TempFile();
$stdout_handle = fopen($stdout_file, 'wb');
if (!$stdout_handle) {
throw new Exception(
pht(
'Unable to open stdout temporary file ("%s") for writing.',
$stdout_file));
}
$stderr_handle = fopen($stderr_file, 'wb');
if (!$stderr_handle) {
throw new Exception(
pht(
'Unable to open stderr temporary file ("%s") for writing.',
$stderr_file));
}
$spec = array(
0 => self::$descriptorSpec[0],
1 => $stdout_handle,
2 => $stderr_handle,
);
}
$proc = @proc_open(
$unmasked_command,
$spec,
$pipes,
$cwd,
$env,
array(
'bypass_shell' => true,
));
if ($trap) {
$err = $trap->getErrorsAsString();
$trap->destroy();
} else {
$err = error_get_last();
if ($err) {
$err = $err['message'];
}
}
if ($is_windows) {
fclose($stdout_handle);
fclose($stderr_handle);
}
if (!is_resource($proc)) {
// When you run an invalid command on a Linux system, the "proc_open()"
// works and then the process (really a "/bin/sh -c ...") exits after
// it fails to resolve the command.
// When you run an invalid command on a Windows system, we bypass the
// shell and the "proc_open()" itself fails. See also T13504. Fail the
// future immediately, acting as though it exited with an error code
// for consistency with Linux.
$result = array(
1,
'',
pht(
'Call to "proc_open()" to open a subprocess failed: %s',
$err),
);
$this->recordResult($result);
return true;
}
if ($is_windows) {
$stdout_handle = fopen($stdout_file, 'rb');
if (!$stdout_handle) {
throw new Exception(
pht(
'Unable to open stdout temporary file ("%s") for reading.',
$stdout_file));
}
$stderr_handle = fopen($stderr_file, 'rb');
if (!$stderr_handle) {
throw new Exception(
pht(
'Unable to open stderr temporary file ("%s") for reading.',
$stderr_file));
}
$pipes = array(
0 => $pipes[0],
1 => $stdout_handle,
2 => $stderr_handle,
);
$this->windowsStdoutTempFile = $stdout_file;
$this->windowsStderrTempFile = $stderr_file;
}
$this->pipes = $pipes;
$this->proc = $proc;
list($stdin, $stdout, $stderr) = $pipes;
if (!$is_windows) {
// On Windows, we redirect process standard output and standard error
// through temporary files. Files don't block, so we don't need to make
// these streams nonblocking.
if ((!stream_set_blocking($stdout, false)) ||
(!stream_set_blocking($stderr, false)) ||
(!stream_set_blocking($stdin, false))) {
$this->__destruct();
throw new Exception(pht('Failed to set streams nonblocking.'));
}
}
$this->tryToCloseStdin();
return false;
}
if (!$this->proc) {
return true;
}
list($stdin, $stdout, $stderr) = $this->pipes;
while (isset($this->stdin) && $this->stdin->getByteLength()) {
$write_segment = $this->stdin->getAnyPrefix();
try {
$bytes = fwrite($stdin, $write_segment);
} catch (RuntimeException $ex) {
// If the subprocess has exited, we may get a broken pipe error here
// in recent versions of PHP. There does not seem to be any way to
// get the actual error code other than reading the exception string.
// For now, treat this as if writes are blocked.
break;
}
if ($bytes === false) {
throw new Exception(pht('Unable to write to stdin!'));
} else if ($bytes) {
$this->stdin->removeBytesFromHead($bytes);
} else {
// Writes are blocked for now.
break;
}
}
$this->tryToCloseStdin();
// Read status before reading pipes so that we can never miss data that
// arrives between our last read and the process exiting.
$status = $this->procGetStatus();
$read_buffer_size = $this->readBufferSize;
$max_stdout_read_bytes = PHP_INT_MAX;
$max_stderr_read_bytes = PHP_INT_MAX;
if ($read_buffer_size !== null) {
$stdout_len = $this->getStdoutBufferLength();
$stderr_len = $this->getStderrBufferLength();
$max_stdout_read_bytes = $read_buffer_size - $stdout_len;
$max_stderr_read_bytes = $read_buffer_size - $stderr_len;
}
if ($max_stdout_read_bytes > 0) {
$this->stdout .= $this->readAndDiscard(
$stdout,
$this->getStdoutSizeLimit() - $this->getStdoutBufferLength(),
'stdout',
$max_stdout_read_bytes);
}
if ($max_stderr_read_bytes > 0) {
$this->stderr .= $this->readAndDiscard(
$stderr,
$this->getStderrSizeLimit() - $this->getStderrBufferLength(),
'stderr',
$max_stderr_read_bytes);
}
$is_done = false;
if (!$status['running']) {
// We may still have unread bytes on stdout or stderr, particularly if
// this future is being buffered and streamed. If we do, we don't want to
// consider the subprocess to have exited until we've read everything.
// See T9724 for context.
if (feof($stdout) && feof($stderr)) {
$is_done = true;
}
}
if ($is_done) {
$signal_info = null;
// If the subprocess got nuked with `kill -9`, we get a -1 exitcode.
// Upgrade this to a slightly more informative value by examining the
// terminating signal code.
$err = $status['exitcode'];
if ($err == -1) {
if ($status['signaled']) {
$signo = $status['termsig'];
$err = 128 + $signo;
$signal_info = pht(
"<Process was terminated by signal %s (%d).>\n\n",
phutil_get_signal_name($signo),
$signo);
}
}
$result = array(
$err,
$this->stdout,
$signal_info.$this->stderr,
);
$this->recordResult($result);
$this->closeProcess();
return true;
}
$elapsed = (microtime(true) - $this->start);
if ($this->terminateTimeout && ($elapsed >= $this->terminateTimeout)) {
if (!$this->didTerminate) {
$this->killedByTimeout = true;
$this->sendTerminateSignal();
return false;
}
}
if ($this->killTimeout && ($elapsed >= $this->killTimeout)) {
$this->killedByTimeout = true;
$this->resolveKill();
return true;
}
}
/**
* @return void
* @task internal
*/
public function __destruct() {
if (!$this->proc) {
return;
}
// NOTE: If we try to proc_close() an open process, we hang indefinitely. To
// avoid this, kill the process explicitly if it's still running.
$status = $this->procGetStatus();
if ($status['running']) {
$this->sendTerminateSignal();
if (!$this->waitForExit(5)) {
$this->resolveKill();
}
} else {
$this->closeProcess();
}
}
/**
* Close and free resources if necessary.
*
* @return void
* @task internal
*/
private function closeProcess() {
foreach ($this->pipes as $pipe) {
if (isset($pipe)) {
@fclose($pipe);
}
}
$this->pipes = array(null, null, null);
if ($this->proc) {
@proc_close($this->proc);
$this->proc = null;
}
$this->stdin = null;
unset($this->windowsStdoutTempFile);
unset($this->windowsStderrTempFile);
}
/**
* Execute `proc_get_status()`, but avoid pitfalls.
*
* @return dict Process status.
* @task internal
*/
private function procGetStatus() {
// After the process exits, we only get one chance to read proc_get_status()
// before it starts returning garbage. Make sure we don't throw away the
// last good read.
if ($this->procStatus) {
if (!$this->procStatus['running']) {
return $this->procStatus;
}
}
// See T13555. This may occur if you call "getPID()" on a future which
// exited immediately without ever creating a valid subprocess.
if (!$this->proc) {
throw new Exception(
pht(
'Attempting to get subprocess status in "ExecFuture" with no '.
'valid subprocess.'));
}
$this->procStatus = proc_get_status($this->proc);
return $this->procStatus;
}
/**
* Try to close stdin, if we're done using it. This keeps us from hanging if
* the process on the other end of the pipe is waiting for EOF.
*
* @return void
* @task internal
*/
private function tryToCloseStdin() {
if (!$this->closePipe) {
// We've been told to keep the pipe open by a call to write(..., true).
return;
}
if ($this->stdin->getByteLength()) {
// We still have bytes to write.
return;
}
list($stdin) = $this->pipes;
if (!$stdin) {
// We've already closed stdin.
return;
}
// There's nothing stopping us from closing stdin, so close it.
@fclose($stdin);
$this->pipes[0] = null;
}
public function getDefaultWait() {
$wait = parent::getDefaultWait();
$next_timeout = $this->getNextTimeout();
if ($next_timeout) {
if (!$this->start) {
$this->start = microtime(true);
}
$elapsed = (microtime(true) - $this->start);
$wait = max(0, min($next_timeout - $elapsed, $wait));
}
return $wait;
}
private function getNextTimeout() {
if ($this->didTerminate) {
return $this->killTimeout;
} else {
return $this->terminateTimeout;
}
}
private function sendTerminateSignal() {
$this->didTerminate = true;
proc_terminate($this->proc);
return $this;
}
private function waitForExit($duration) {
$start = microtime(true);
while (true) {
$status = $this->procGetStatus();
if (!$status['running']) {
return true;
}
$waited = (microtime(true) - $start);
if ($waited > $duration) {
return false;
}
}
}
protected function getServiceProfilerStartParameters() {
return array(
'type' => 'exec',
'command' => phutil_string_cast($this->getCommand()),
);
}
protected function getServiceProfilerResultParameters() {
if ($this->hasResult()) {
$result = $this->getResult();
$err = idx($result, 0);
} else {
$err = null;
}
return array(
'err' => $err,
);
}
private function getStdoutBufferLength() {
if ($this->stdout === null) {
return 0;
}
return strlen($this->stdout);
}
private function getStderrBufferLength() {
if ($this->stderr === null) {
return 0;
}
return strlen($this->stderr);
}
}
diff --git a/src/future/exec/PhutilExecutableFuture.php b/src/future/exec/PhutilExecutableFuture.php
index b4c490b9..696815be 100644
--- a/src/future/exec/PhutilExecutableFuture.php
+++ b/src/future/exec/PhutilExecutableFuture.php
@@ -1,232 +1,232 @@
<?php
/**
* @task config Configuring the Command
*/
abstract class PhutilExecutableFuture extends Future {
private $command;
private $env;
private $cwd;
private $resolveOnError = true;
final public function __construct($pattern /* , ... */) {
$args = func_get_args();
if ($pattern instanceof PhutilCommandString) {
if (count($args) !== 1) {
throw new Exception(
pht(
'Command (of class "%s") was constructed with a '.
'"PhutilCommandString", but also passed arguments. '.
'When using a prebuilt command, you must not pass '.
'arguments.',
get_class($this)));
}
$this->command = $pattern;
} else {
$this->command = call_user_func_array('csprintf', $args);
}
$this->didConstruct();
}
protected function didConstruct() {
return;
}
final public function setResolveOnError($resolve_on_error) {
$this->resolveOnError = $resolve_on_error;
return $this;
}
final public function getResolveOnError() {
return $this->resolveOnError;
}
final public function getCommand() {
return $this->command;
}
/**
* Set environmental variables for the command.
*
* By default, variables are added to the environment of this process. You
* can optionally wipe the environment and pass only the specified values.
*
* // Env will have "X" and current env ("PATH", etc.)
* $exec->setEnv(array('X' => 'y'));
*
* // Env will have ONLY "X".
* $exec->setEnv(array('X' => 'y'), $wipe_process_env = true);
*
* @param map<string, string> $env Dictionary of environmental variables.
* @param bool $wipe_process_env (optional) Pass `true` to replace the
* existing environment.
- * @return this
+ * @return $this
*
* @task config
*/
final public function setEnv(array $env, $wipe_process_env = false) {
// Force values to strings here. The underlying PHP commands get upset if
// they are handed non-string values as environmental variables.
foreach ($env as $key => $value) {
$env[$key] = (string)$value;
}
if (!$wipe_process_env) {
$env = $env + $this->getEnv();
}
$this->env = $env;
return $this;
}
/**
* Set the value of a specific environmental variable for this command.
*
* @param string $key Environmental variable name.
* @param string|null $value New value, or null to remove this variable.
- * @return this
+ * @return $this
* @task config
*/
final public function updateEnv($key, $value) {
$env = $this->getEnv();
if ($value === null) {
unset($env[$key]);
} else {
$env[$key] = (string)$value;
}
$this->env = $env;
return $this;
}
/**
* Returns `true` if this command has a configured environment.
*
* @return bool True if this command has an environment.
* @task config
*/
final public function hasEnv() {
return ($this->env !== null);
}
/**
* Get the configured environment.
*
* @return map<string, string> Effective environment for this command.
* @task config
*/
final public function getEnv() {
if (!$this->hasEnv()) {
$default_env = $_ENV;
// If `variables_order` does not include "E", the $_ENV superglobal
// does not build and there's no apparent reasonable way for us to
// rebuild it (we could perhaps parse the output of `export`).
// For now, explicitly populate variables which we rely on and which
// we know may exist. After T12071, we should be able to rely on
// $_ENV and no longer need to do this.
$known_keys = array(
'PHABRICATOR_ENV',
'PHABRICATOR_INSTANCE',
);
foreach ($known_keys as $known_key) {
$value = getenv($known_key);
if (strlen($value)) {
$default_env[$known_key] = $value;
}
}
$this->setEnv($default_env, $wipe_process_env = true);
}
return $this->env;
}
/**
* Set the current working directory for the subprocess (that is, set where
* the subprocess will execute). If not set, the default value is the parent's
* current working directory.
*
* @param string $cwd Directory to execute the subprocess in.
- * @return this
+ * @return $this
* @task config
*/
final public function setCWD($cwd) {
$cwd = phutil_string_cast($cwd);
try {
Filesystem::assertExists($cwd);
} catch (FilesystemException $ex) {
throw new PhutilProxyException(
pht(
'Unable to run a command in directory "%s".',
$cwd),
$ex);
}
if (!is_dir($cwd)) {
throw new Exception(
pht(
'Preparing to run a command in directory "%s", but that path is '.
'not a directory.',
$cwd));
}
// Although you don't technically need read permission to "chdir()" into
// a directory, it is almost certainly a mistake to execute a subprocess
// in a CWD we can't read. Refuse to do this. If callers have some
// exceptionally clever scheme up their sleeves they can always have the
// subprocess "cd" or "chdir()" explicitly.
if (!is_readable($cwd)) {
throw new Exception(
pht(
'Preparing to run a command in directory "%s", but that directory '.
'is not readable (the current process does not have "+r" '.
'permission).',
$cwd));
}
if (phutil_is_windows()) {
// Do nothing. On Windows, calling "is_executable(...)" on a directory
// always appears to return "false". Skip this check under Windows.
} else if (!is_executable($cwd)) {
throw new Exception(
pht(
'Preparing to run a command in directory "%s", but that directory '.
'is not executable (the current process does not have "+x" '.
'permission).',
$cwd));
}
$this->cwd = $cwd;
return $this;
}
/**
* Get the command's current working directory.
*
* @return string Working directory.
* @task config
*/
final public function getCWD() {
return $this->cwd;
}
}
diff --git a/src/future/http/BaseHTTPFuture.php b/src/future/http/BaseHTTPFuture.php
index 53929be1..0c29117f 100644
--- a/src/future/http/BaseHTTPFuture.php
+++ b/src/future/http/BaseHTTPFuture.php
@@ -1,460 +1,460 @@
<?php
/**
* Execute HTTP requests with a future-oriented API. For example:
*
* $future = new HTTPFuture('http://www.example.com/');
* list($status, $body, $headers) = $future->resolve();
*
* This is an abstract base class which defines the API that HTTP futures
* conform to. Concrete implementations are available in @{class:HTTPFuture}
* and @{class:HTTPSFuture}. All futures return a <status, body, header> tuple
* when resolved; status is an object of class @{class:HTTPFutureResponseStatus}
* and may represent any of a wide variety of errors in the transport layer,
* a support library, or the actual HTTP exchange.
*
* @task create Creating a New Request
* @task config Configuring the Request
* @task resolve Resolving the Request
* @task internal Internals
*/
abstract class BaseHTTPFuture extends Future {
private $method = 'GET';
private $timeout = 300.0;
private $headers = array();
private $uri;
private $data;
private $expect;
private $disableContentDecoding;
/* -( Creating a New Request )--------------------------------------------- */
/**
* Build a new future which will make an HTTP request to a given URI, with
* some optional data payload. Since this class is abstract you can't actually
* instantiate it; instead, build a new @{class:HTTPFuture} or
* @{class:HTTPSFuture}.
*
* @param string $uri Fully-qualified URI to send a request to.
* @param mixed $data (optional) String or array to include in the request.
* Strings will be transmitted raw; arrays will be encoded and
* sent as 'application/x-www-form-urlencoded'.
* @task create
*/
final public function __construct($uri, $data = array()) {
$this->setURI((string)$uri);
$this->setData($data);
}
/* -( Configuring the Request )-------------------------------------------- */
/**
* Set a timeout for the service call. If the request hasn't resolved yet,
* the future will resolve with a status that indicates the request timed
* out. You can determine if a status is a timeout status by calling
* isTimeout() on the status object.
*
* @param float $timeout Maximum timeout, in seconds.
- * @return this
+ * @return $this
* @task config
*/
public function setTimeout($timeout) {
$this->timeout = $timeout;
return $this;
}
/**
* Get the currently configured timeout.
*
* @return float Maximum number of seconds the request will execute for.
* @task config
*/
public function getTimeout() {
return $this->timeout;
}
/**
* Select the HTTP method (e.g., "GET", "POST", "PUT") to use for the request.
* By default, requests use "GET".
*
* @param string $method HTTP method name.
- * @return this
+ * @return $this
* @task config
*/
final public function setMethod($method) {
static $supported_methods = array(
'GET' => true,
'POST' => true,
'PUT' => true,
'DELETE' => true,
);
if (empty($supported_methods[$method])) {
throw new Exception(
pht(
"The HTTP method '%s' is not supported. Supported HTTP methods ".
"are: %s.",
$method,
implode(', ', array_keys($supported_methods))));
}
$this->method = $method;
return $this;
}
/**
* Get the HTTP method the request will use.
*
* @return string HTTP method name, like "GET".
* @task config
*/
final public function getMethod() {
return $this->method;
}
/**
* Set the URI to send the request to. Note that this is also a constructor
* parameter.
*
* @param string $uri URI to send the request to.
- * @return this
+ * @return $this
* @task config
*/
public function setURI($uri) {
$this->uri = (string)$uri;
return $this;
}
/**
* Get the fully-qualified URI the request will be made to.
*
* @return string URI the request will be sent to.
* @task config
*/
public function getURI() {
return $this->uri;
}
/**
* Provide data to send along with the request. Note that this is also a
* constructor parameter; it may be more convenient to provide it there. Data
* must be a string (in which case it will be sent raw) or an array (in which
* case it will be encoded and sent as 'application/x-www-form-urlencoded').
*
* @param mixed $data Data to send with the request.
- * @return this
+ * @return $this
* @task config
*/
public function setData($data) {
if (!is_string($data) && !is_array($data)) {
throw new Exception(pht('Data parameter must be an array or string.'));
}
$this->data = $data;
return $this;
}
/**
* Get the data which will be sent with the request.
*
* @return mixed Data which will be sent.
* @task config
*/
public function getData() {
return $this->data;
}
/**
* Add an HTTP header to the request. The same header name can be specified
* more than once, which will cause multiple headers to be sent.
*
* @param string $name Header name, like "Accept-Language".
* @param string $value Header value, like "en-us".
- * @return this
+ * @return $this
* @task config
*/
public function addHeader($name, $value) {
$this->headers[] = array($name, $value);
return $this;
}
/**
* Get headers which will be sent with the request. Optionally, you can
* provide a filter, which will return only headers with that name. For
* example:
*
* $all_headers = $future->getHeaders();
* $just_user_agent = $future->getHeaders('User-Agent');
*
* In either case, an array with all (or all matching) headers is returned.
*
* @param string|null $filter (optional) Filter, which selects only headers
* with that name if provided.
* @return array List of all (or all matching) headers.
* @task config
*/
public function getHeaders($filter = null) {
if ($filter !== null) {
$filter = phutil_utf8_strtolower($filter);
}
$result = array();
foreach ($this->headers as $header) {
list($name, $value) = $header;
if (($filter === null) || ($filter === phutil_utf8_strtolower($name))) {
$result[] = $header;
}
}
return $result;
}
/**
* Set the status codes that are expected in the response.
* If set, isError on the status object will return true for status codes
* that are not in the input array. Otherwise, isError will be true for any
* HTTP status code outside the 2xx range (notwithstanding other errors such
* as connection or transport issues).
*
* @param array|null $status_codes List of expected HTTP status codes.
*
- * @return this
+ * @return $this
* @task config
*/
public function setExpectStatus($status_codes) {
$this->expect = $status_codes;
return $this;
}
/**
* Return list of expected status codes, or null if not set.
*
* @return array|null List of expected status codes.
*/
public function getExpectStatus() {
return $this->expect;
}
/**
* Add a HTTP basic authentication header to the request.
*
* @param string $username Username to authenticate with.
* @param PhutilOpaqueEnvelope $password Password to authenticate with.
- * @return this
+ * @return $this
* @task config
*/
public function setHTTPBasicAuthCredentials(
$username,
PhutilOpaqueEnvelope $password) {
$password_plaintext = $password->openEnvelope();
$credentials = base64_encode($username.':'.$password_plaintext);
return $this->addHeader('Authorization', 'Basic '.$credentials);
}
public function getHTTPRequestByteLength() {
// NOTE: This isn't very accurate, but it's only used by the "--trace"
// call profiler to help pick out huge requests.
$data = $this->getData();
if (is_scalar($data)) {
return strlen($data);
}
return strlen(phutil_build_http_querystring($data));
}
public function setDisableContentDecoding($disable_decoding) {
$this->disableContentDecoding = $disable_decoding;
return $this;
}
public function getDisableContentDecoding() {
return $this->disableContentDecoding;
}
/* -( Resolving the Request )---------------------------------------------- */
/**
* Exception-oriented @{method:resolve}. Throws if the status indicates an
* error occurred.
*
* @return tuple HTTP request result <body, headers> tuple.
* @task resolve
*/
final public function resolvex() {
$result = $this->resolve();
list($status, $body, $headers) = $result;
if ($status->isError()) {
throw $status;
}
return array($body, $headers);
}
/* -( Internals )---------------------------------------------------------- */
/**
* Parse a raw HTTP response into a <status, body, headers> tuple.
*
* @param string $raw_response Raw HTTP response.
* @return tuple Valid resolution tuple.
* @task internal
*/
protected function parseRawHTTPResponse($raw_response) {
$rex_base = "@^(?P<head>.*?)\r?\n\r?\n(?P<body>.*)$@s";
$rex_head = "@^HTTP/\S+ (?P<code>\d+) ?(?P<status>.*?)".
"(?:\r?\n(?P<headers>.*))?$@s";
// We need to parse one or more header blocks in case we got any
// "HTTP/1.X 100 Continue" nonsense back as part of the response. This
// happens with HTTPS requests, at the least.
$response = $raw_response;
while (true) {
$matches = null;
if (!preg_match($rex_base, $response, $matches)) {
return $this->buildMalformedResult($raw_response);
}
$head = $matches['head'];
$body = $matches['body'];
if (!preg_match($rex_head, $head, $matches)) {
return $this->buildMalformedResult($raw_response);
}
$response_code = (int)$matches['code'];
$response_status = strtolower($matches['status']);
if ($response_code == 100) {
// This is HTTP/1.X 100 Continue, so this whole chunk is moot.
$response = $body;
} else if (($response_code == 200) &&
($response_status == 'connection established')) {
// When tunneling through an HTTPS proxy, we get an initial header
// block like "HTTP/1.X 200 Connection established", then newlines,
// then the normal response. Drop this chunk.
$response = $body;
} else {
$headers = $this->parseHeaders(idx($matches, 'headers'));
break;
}
}
if (!$this->getDisableContentDecoding()) {
$content_encoding = null;
foreach ($headers as $header) {
list($name, $value) = $header;
$name = phutil_utf8_strtolower($name);
if (!strcasecmp($name, 'Content-Encoding')) {
$content_encoding = $value;
break;
}
}
switch ($content_encoding) {
case 'gzip':
$decoded_body = @gzdecode($body);
if ($decoded_body === false) {
return $this->buildMalformedResult($raw_response);
}
$body = $decoded_body;
break;
}
}
$status = new HTTPFutureHTTPResponseStatus(
$response_code,
$body,
$headers,
$this->expect);
return array($status, $body, $headers);
}
/**
* Parse an HTTP header block.
*
* @param string $head_raw Raw HTTP headers.
* @return list List of HTTP header tuples.
* @task internal
*/
protected function parseHeaders($head_raw) {
$rex_header = '@^(?P<name>.*?):\s*(?P<value>.*)$@';
$headers = array();
if (!$head_raw) {
return $headers;
}
$headers_raw = preg_split("/\r?\n/", $head_raw);
foreach ($headers_raw as $header) {
$m = null;
if (preg_match($rex_header, $header, $m)) {
$headers[] = array($m['name'], $m['value']);
} else {
$headers[] = array($header, null);
}
}
return $headers;
}
/**
* Find value of the first header with given name.
*
* @param list $headers List of headers from `resolve()`.
* @param string $search Case insensitive header name.
* @return string|null Value of the header or null if not found.
* @task resolve
*/
public static function getHeader(array $headers, $search) {
assert_instances_of($headers, 'array');
foreach ($headers as $header) {
list($name, $value) = $header;
if (strcasecmp($name, $search) == 0) {
return $value;
}
}
return null;
}
/**
* Build a result tuple indicating a parse error resulting from a malformed
* HTTP response.
*
* @return tuple Valid resolution tuple.
* @task internal
*/
protected function buildMalformedResult($raw_response) {
$body = null;
$headers = array();
$status = new HTTPFutureParseResponseStatus(
HTTPFutureParseResponseStatus::ERROR_MALFORMED_RESPONSE,
$raw_response);
return array($status, $body, $headers);
}
}
diff --git a/src/future/http/HTTPSFuture.php b/src/future/http/HTTPSFuture.php
index 22f78ea5..fae99698 100644
--- a/src/future/http/HTTPSFuture.php
+++ b/src/future/http/HTTPSFuture.php
@@ -1,880 +1,880 @@
<?php
/**
* Very basic HTTPS future.
*/
final class HTTPSFuture extends BaseHTTPFuture {
private static $multi;
private static $results = array();
private static $pool = array();
private static $globalCABundle;
private $handle;
private $profilerCallID;
private $cabundle;
private $followLocation = true;
private $responseBuffer = '';
private $responseBufferPos;
private $files = array();
private $temporaryFiles = array();
private $rawBody;
private $rawBodyPos = 0;
private $fileHandle;
private $downloadPath;
private $downloadHandle;
private $parser;
private $progressSink;
private $curlOptions = array();
/**
* Create a temp file containing an SSL cert, and use it for this session.
*
* This allows us to do host-specific SSL certificates in whatever client
* is using libphutil. e.g. in Arcanist, you could add an "ssl_cert" key
* to a specific host in ~/.arcrc and use that.
*
* cURL needs this to be a file, it doesn't seem to be able to handle a string
* which contains the cert. So we make a temporary file and store it there.
*
* @param string $certificate The multi-line, possibly lengthy, SSL
* certificate to use.
- * @return this
+ * @return $this
*/
public function setCABundleFromString($certificate) {
$temp = new TempFile();
Filesystem::writeFile($temp, $certificate);
$this->cabundle = $temp;
return $this;
}
/**
* Set the SSL certificate to use for this session, given a path.
*
* @param string $path The path to a valid SSL certificate for this session
- * @return this
+ * @return $this
*/
public function setCABundleFromPath($path) {
$this->cabundle = $path;
return $this;
}
/**
* Get the path to the SSL certificate for this session.
*
* @return string|null
*/
public function getCABundle() {
return $this->cabundle;
}
/**
* Set whether Location headers in the response will be respected.
* The default is true.
*
* @param boolean $follow true to follow any Location header present in the
* response, false to return the request directly
- * @return this
+ * @return $this
*/
public function setFollowLocation($follow) {
$this->followLocation = $follow;
return $this;
}
/**
* Get whether Location headers in the response will be respected.
*
* @return boolean
*/
public function getFollowLocation() {
return $this->followLocation;
}
/**
* Set the fallback CA certificate if one is not specified
* for the session, given a path.
*
* @param string $path The path to a valid SSL certificate
* @return void
*/
public static function setGlobalCABundleFromPath($path) {
self::$globalCABundle = $path;
}
/**
* Set the fallback CA certificate if one is not specified
* for the session, given a string.
*
* @param string $certificate The certificate
* @return void
*/
public static function setGlobalCABundleFromString($certificate) {
$temp = new TempFile();
Filesystem::writeFile($temp, $certificate);
self::$globalCABundle = $temp;
}
/**
* Get the fallback global CA certificate
*
* @return string
*/
public static function getGlobalCABundle() {
return self::$globalCABundle;
}
/**
* Load contents of remote URI. Behaves pretty much like
* `@file_get_contents($uri)` but doesn't require `allow_url_fopen`.
*
* @param string $uri
* @param float $timeout (optional)
* @return string|false
*/
public static function loadContent($uri, $timeout = null) {
$future = new self($uri);
if ($timeout !== null) {
$future->setTimeout($timeout);
}
try {
list($body) = $future->resolvex();
return $body;
} catch (HTTPFutureResponseStatus $ex) {
return false;
}
}
public function setDownloadPath($download_path) {
$this->downloadPath = $download_path;
if (Filesystem::pathExists($download_path)) {
throw new Exception(
pht(
'Specified download path "%s" already exists, refusing to '.
'overwrite.',
$download_path));
}
return $this;
}
public function setProgressSink(PhutilProgressSink $progress_sink) {
$this->progressSink = $progress_sink;
return $this;
}
public function getProgressSink() {
return $this->progressSink;
}
/**
* See T13533. This supports an install-specific Kerberos workflow.
*/
public function addCURLOption($option_key, $option_value) {
if (!is_scalar($option_key)) {
throw new Exception(
pht(
'Expected option key passed to "addCurlOption(<key>, ...)" to be '.
'a scalar, got "%s".',
phutil_describe_type($option_key)));
}
$this->curlOptions[] = array($option_key, $option_value);
return $this;
}
/**
* Attach a file to the request.
*
* @param string $key HTTP parameter name.
* @param string $data File content.
* @param string $name File name.
* @param string $mime_type File mime type.
- * @return this
+ * @return $this
*/
public function attachFileData($key, $data, $name, $mime_type) {
if (isset($this->files[$key])) {
throw new Exception(
pht(
'%s currently supports only one file attachment for each '.
'parameter name. You are trying to attach two different files with '.
'the same parameter, "%s".',
__CLASS__,
$key));
}
$this->files[$key] = array(
'data' => $data,
'name' => $name,
'mime' => $mime_type,
);
return $this;
}
public function isReady() {
if ($this->hasResult()) {
return true;
}
$uri = $this->getURI();
$domain = id(new PhutilURI($uri))->getDomain();
$is_download = $this->isDownload();
// See T13396. For now, use the streaming response parser only if we're
// downloading the response to disk.
$use_streaming_parser = (bool)$is_download;
if (!$this->handle) {
$uri_object = new PhutilURI($uri);
$proxy = PhutilHTTPEngineExtension::buildHTTPProxyURI($uri_object);
// TODO: Currently, the "proxy" is not passed to the ServiceProfiler
// because of changes to how ServiceProfiler is integrated. It would
// be nice to pass it again.
if (!self::$multi) {
self::$multi = curl_multi_init();
if (!self::$multi) {
throw new Exception(pht('%s failed!', 'curl_multi_init()'));
}
}
if (!empty(self::$pool[$domain])) {
$curl = array_pop(self::$pool[$domain]);
} else {
$curl = curl_init();
if (!$curl) {
throw new Exception(pht('%s failed!', 'curl_init()'));
}
}
$this->handle = $curl;
curl_multi_add_handle(self::$multi, $curl);
curl_setopt($curl, CURLOPT_URL, $uri);
if (defined('CURLOPT_PROTOCOLS')) {
// cURL supports a lot of protocols, and by default it will honor
// redirects across protocols (for instance, from HTTP to POP3). Beyond
// being very silly, this also has security implications:
//
// http://blog.volema.com/curl-rce.html
//
// Disable all protocols other than HTTP and HTTPS.
$allowed_protocols = CURLPROTO_HTTPS | CURLPROTO_HTTP;
curl_setopt($curl, CURLOPT_PROTOCOLS, $allowed_protocols);
curl_setopt($curl, CURLOPT_REDIR_PROTOCOLS, $allowed_protocols);
}
if ($this->rawBody !== null) {
if ($this->getData()) {
throw new Exception(
pht(
'You can not execute an HTTP future with both a raw request '.
'body and structured request data.'));
}
// We aren't actually going to use this file handle, since we are
// just pushing data through the callback, but cURL gets upset if
// we don't hand it a real file handle.
$tmp = new TempFile();
$this->fileHandle = fopen($tmp, 'r');
// NOTE: We must set CURLOPT_PUT here to make cURL use CURLOPT_INFILE.
// We'll possibly overwrite the method later on, unless this is really
// a PUT request.
curl_setopt($curl, CURLOPT_PUT, true);
curl_setopt($curl, CURLOPT_INFILE, $this->fileHandle);
curl_setopt($curl, CURLOPT_INFILESIZE, strlen($this->rawBody));
curl_setopt($curl, CURLOPT_READFUNCTION,
array($this, 'willWriteBody'));
} else {
$data = $this->formatRequestDataForCURL();
curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
}
$headers = $this->getHeaders();
$saw_expect = false;
$saw_accept = false;
for ($ii = 0; $ii < count($headers); $ii++) {
list($name, $value) = $headers[$ii];
$headers[$ii] = $name.': '.$value;
if (!strcasecmp($name, 'Expect')) {
$saw_expect = true;
}
if (!strcasecmp($name, 'Accept-Encoding')) {
$saw_accept = true;
}
}
if (!$saw_expect) {
// cURL sends an "Expect" header by default for certain requests. While
// there is some reasoning behind this, it causes a practical problem
// in that lighttpd servers reject these requests with a 417. Both sides
// are locked in an eternal struggle (lighttpd has introduced a
// 'server.reject-expect-100-with-417' option to deal with this case).
//
// The ostensibly correct way to suppress this behavior on the cURL side
// is to add an empty "Expect:" header. If we haven't seen some other
// explicit "Expect:" header, do so.
//
// See here, for example, although this issue is fairly widespread:
// http://curl.haxx.se/mail/archive-2009-07/0008.html
$headers[] = 'Expect:';
}
if (!$saw_accept) {
if (!$use_streaming_parser) {
if ($this->canAcceptGzip()) {
$headers[] = 'Accept-Encoding: gzip';
}
}
}
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
// Set the requested HTTP method, e.g. GET / POST / PUT.
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $this->getMethod());
// Make sure we get the headers and data back.
curl_setopt($curl, CURLOPT_HEADER, true);
curl_setopt($curl, CURLOPT_WRITEFUNCTION,
array($this, 'didReceiveDataCallback'));
if ($this->followLocation) {
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curl, CURLOPT_MAXREDIRS, 20);
}
if (defined('CURLOPT_TIMEOUT_MS')) {
// If CURLOPT_TIMEOUT_MS is available, use the higher-precision timeout.
$timeout = max(1, ceil(1000 * $this->getTimeout()));
curl_setopt($curl, CURLOPT_TIMEOUT_MS, $timeout);
} else {
// Otherwise, fall back to the lower-precision timeout.
$timeout = max(1, ceil($this->getTimeout()));
curl_setopt($curl, CURLOPT_TIMEOUT, $timeout);
}
// We're going to try to set CAINFO below. This doesn't work at all on
// OSX around Yosemite (see T5913). On these systems, we'll use the
// system CA and then try to tell the user that their settings were
// ignored and how to fix things if we encounter a CA-related error.
// Assume we have custom CA settings to start with; we'll clear this
// flag if we read the default CA info below.
// Try some decent fallbacks here:
// - First, check if a bundle is set explicitly for this request, via
// `setCABundle()` or similar.
// - Then, check if a global bundle is set explicitly for all requests,
// via `setGlobalCABundle()` or similar.
// - Then, if a local custom.pem exists, use that, because it probably
// means that the user wants to override everything (also because the
// user might not have access to change the box's php.ini to add
// curl.cainfo).
// - Otherwise, try using curl.cainfo. If it's set explicitly, it's
// probably reasonable to try using it before we fall back to what
// libphutil ships with.
// - Lastly, try the default that libphutil ships with. If it doesn't
// work, give up and yell at the user.
if (!$this->getCABundle()) {
$caroot = dirname(phutil_get_library_root('arcanist'));
$caroot = $caroot.'/resources/ssl/';
$ini_val = ini_get('curl.cainfo');
if (self::getGlobalCABundle()) {
$this->setCABundleFromPath(self::getGlobalCABundle());
} else if (Filesystem::pathExists($caroot.'custom.pem')) {
$this->setCABundleFromPath($caroot.'custom.pem');
} else if ($ini_val) {
// TODO: We can probably do a pathExists() here, even.
$this->setCABundleFromPath($ini_val);
} else {
$this->setCABundleFromPath($caroot.'default.pem');
}
}
if ($this->canSetCAInfo()) {
curl_setopt($curl, CURLOPT_CAINFO, $this->getCABundle());
}
$verify_peer = 1;
$verify_host = 2;
$extensions = PhutilHTTPEngineExtension::getAllExtensions();
foreach ($extensions as $extension) {
if ($extension->shouldTrustAnySSLAuthorityForURI($uri_object)) {
$verify_peer = 0;
}
if ($extension->shouldTrustAnySSLHostnameForURI($uri_object)) {
$verify_host = 0;
}
}
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, $verify_peer);
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, $verify_host);
curl_setopt($curl, CURLOPT_SSLVERSION, 0);
// See T13391. Recent versions of cURL default to "HTTP/2" on some
// connections, but do not support HTTP/2 proxies. Until HTTP/2
// stabilizes, force HTTP/1.1 explicitly.
curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
if ($proxy) {
curl_setopt($curl, CURLOPT_PROXY, (string)$proxy);
}
foreach ($this->curlOptions as $curl_option) {
list($curl_key, $curl_value) = $curl_option;
try {
$ok = curl_setopt($curl, $curl_key, $curl_value);
if (!$ok) {
throw new Exception(
pht(
'Call to "curl_setopt(...)" returned "false".'));
}
} catch (Exception $ex) {
throw new PhutilProxyException(
pht(
'Call to "curl_setopt(...) failed for option key "%s".',
$curl_key),
$ex);
}
}
if ($is_download) {
$this->downloadHandle = @fopen($this->downloadPath, 'wb+');
if (!$this->downloadHandle) {
throw new Exception(
pht(
'Failed to open filesystem path "%s" for writing.',
$this->downloadPath));
}
}
if ($use_streaming_parser) {
$streaming_parser = id(new PhutilHTTPResponseParser())
->setFollowLocationHeaders($this->getFollowLocation());
if ($this->downloadHandle) {
$streaming_parser->setWriteHandle($this->downloadHandle);
}
$progress_sink = $this->getProgressSink();
if ($progress_sink) {
$streaming_parser->setProgressSink($progress_sink);
}
$this->parser = $streaming_parser;
}
} else {
$curl = $this->handle;
if (!self::$results) {
// NOTE: In curl_multi_select(), PHP calls curl_multi_fdset() but does
// not check the return value of &maxfd for -1 until recent versions
// of PHP (5.4.8 and newer). cURL may return -1 as maxfd in some unusual
// situations; if it does, PHP enters select() with nfds=0, which blocks
// until the timeout is reached.
//
// We could try to guess whether this will happen or not by examining
// the version identifier, but we can also just sleep for only a short
// period of time.
curl_multi_select(self::$multi, 0.01);
}
}
do {
$active = null;
$result = curl_multi_exec(self::$multi, $active);
} while ($result == CURLM_CALL_MULTI_PERFORM);
while ($info = curl_multi_info_read(self::$multi)) {
if ($info['msg'] == CURLMSG_DONE) {
self::$results[(int)$info['handle']] = $info;
}
}
if (!array_key_exists((int)$curl, self::$results)) {
return false;
}
// The request is complete, so release any temporary files we wrote
// earlier.
$this->temporaryFiles = array();
$info = self::$results[(int)$curl];
$result = $this->responseBuffer;
$err_code = $info['result'];
if ($err_code) {
if (($err_code == CURLE_SSL_CACERT) && !$this->canSetCAInfo()) {
$status = new HTTPFutureCertificateResponseStatus(
HTTPFutureCertificateResponseStatus::ERROR_IMMUTABLE_CERTIFICATES,
$uri);
} else {
$status = new HTTPFutureCURLResponseStatus($err_code, $uri);
}
$body = null;
$headers = array();
$this->setResult(array($status, $body, $headers));
} else if ($this->parser) {
$streaming_parser = $this->parser;
try {
$responses = $streaming_parser->getResponses();
$final_response = last($responses);
$result = array(
$final_response->getStatus(),
$final_response->getBody(),
$final_response->getHeaders(),
);
} catch (HTTPFutureParseResponseStatus $ex) {
$result = array($ex, null, array());
}
$this->setResult($result);
} else {
// cURL returns headers of all redirects, we strip all but the final one.
$redirects = curl_getinfo($curl, CURLINFO_REDIRECT_COUNT);
$result = preg_replace('/^(.*\r\n\r\n){'.$redirects.'}/sU', '', $result);
$this->setResult($this->parseRawHTTPResponse($result));
}
curl_multi_remove_handle(self::$multi, $curl);
unset(self::$results[(int)$curl]);
// NOTE: We want to use keepalive if possible. Return the handle to a
// pool for the domain; don't close it.
if ($this->shouldReuseHandles()) {
self::$pool[$domain][] = $curl;
}
if ($is_download) {
if ($this->downloadHandle) {
fflush($this->downloadHandle);
fclose($this->downloadHandle);
$this->downloadHandle = null;
}
}
$sink = $this->getProgressSink();
if ($sink) {
$status = head($this->getResult());
if ($status->isError()) {
$sink->didFailWork();
} else {
$sink->didCompleteWork();
}
}
return true;
}
/**
* Callback invoked by cURL as it reads HTTP data from the response. We save
* the data to a buffer.
*/
public function didReceiveDataCallback($handle, $data) {
if ($this->parser) {
$this->parser->readBytes($data);
} else {
$this->responseBuffer .= $data;
}
return strlen($data);
}
/**
* Read data from the response buffer.
*
* NOTE: Like @{class:ExecFuture}, this method advances a read cursor but
* does not discard the data. The data will still be buffered, and it will
* all be returned when the future resolves. To discard the data after
* reading it, call @{method:discardBuffers}.
*
* @return string Response data, if available.
*/
public function read() {
if ($this->isDownload()) {
throw new Exception(
pht(
'You can not read the result buffer while streaming results '.
'to disk: there is no in-memory buffer to read.'));
}
if ($this->parser) {
throw new Exception(
pht(
'Streaming reads are not currently supported by the streaming '.
'parser.'));
}
$result = substr($this->responseBuffer, $this->responseBufferPos);
$this->responseBufferPos = strlen($this->responseBuffer);
return $result;
}
/**
* Discard any buffered data. Normally, you call this after reading the
* data with @{method:read}.
*
- * @return this
+ * @return $this
*/
public function discardBuffers() {
if ($this->isDownload()) {
throw new Exception(
pht(
'You can not discard the result buffer while streaming results '.
'to disk: there is no in-memory buffer to discard.'));
}
if ($this->parser) {
throw new Exception(
pht(
'Buffer discards are not currently supported by the streaming '.
'parser.'));
}
$this->responseBuffer = '';
$this->responseBufferPos = 0;
return $this;
}
/**
* Produces a value safe to pass to `CURLOPT_POSTFIELDS`.
*
* @return wild Some value, suitable for use in `CURLOPT_POSTFIELDS`.
*/
private function formatRequestDataForCURL() {
// We're generating a value to hand to cURL as CURLOPT_POSTFIELDS. The way
// cURL handles this value has some tricky caveats.
// First, we can return either an array or a query string. If we return
// an array, we get a "multipart/form-data" request. If we return a
// query string, we get an "application/x-www-form-urlencoded" request.
// Second, if we return an array we can't duplicate keys. The user might
// want to send the same parameter multiple times.
// Third, if we return an array and any of the values start with "@",
// cURL includes arbitrary files off disk and sends them to an untrusted
// remote server. For example, an array like:
//
// array('name' => '@/usr/local/secret')
//
// ...will attempt to read that file off disk and transmit its contents with
// the request. This behavior is pretty surprising, and it can easily
// become a relatively severe security vulnerability which allows an
// attacker to read any file the HTTP process has access to. Since this
// feature is very dangerous and not particularly useful, we prevent its
// use. Broadly, this means we must reject some requests because they
// contain an "@" in an inconvenient place.
// Generally, to avoid the "@" case and because most servers usually
// expect "application/x-www-form-urlencoded" data, we try to return a
// string unless there are files attached to this request.
$data = $this->getData();
$files = $this->files;
$any_data = ($data || (is_string($data) && strlen($data)));
$any_files = (bool)$this->files;
if (!$any_data && !$any_files) {
// No files or data, so just bail.
return null;
}
if (!$any_files) {
// If we don't have any files, just encode the data as a query string,
// make sure it's not including any files, and we're good to go.
if (is_array($data)) {
$data = phutil_build_http_querystring($data);
}
$this->checkForDangerousCURLMagic($data, $is_query_string = true);
return $data;
}
// If we've made it this far, we have some files, so we need to return
// an array. First, convert the other data into an array if it isn't one
// already.
if (is_string($data)) {
// NOTE: We explicitly don't want fancy array parsing here, so just
// do a basic parse and then convert it into a dictionary ourselves.
$parser = new PhutilQueryStringParser();
$pairs = $parser->parseQueryStringToPairList($data);
$map = array();
foreach ($pairs as $pair) {
list($key, $value) = $pair;
if (array_key_exists($key, $map)) {
throw new Exception(
pht(
'Request specifies two values for key "%s", but parameter '.
'names must be unique if you are posting file data due to '.
'limitations with cURL.',
$key));
}
$map[$key] = $value;
}
$data = $map;
}
foreach ($data as $key => $value) {
$this->checkForDangerousCURLMagic($value, $is_query_string = false);
}
foreach ($this->files as $name => $info) {
if (array_key_exists($name, $data)) {
throw new Exception(
pht(
'Request specifies a file with key "%s", but that key is also '.
'defined by normal request data. Due to limitations with cURL, '.
'requests that post file data must use unique keys.',
$name));
}
$tmp = new TempFile($info['name']);
Filesystem::writeFile($tmp, $info['data']);
$this->temporaryFiles[] = $tmp;
// In 5.5.0 and later, we can use CURLFile. Prior to that, we have to
// use this "@" stuff.
if (class_exists('CURLFile', false)) {
$file_value = new CURLFile((string)$tmp, $info['mime'], $info['name']);
} else {
$file_value = '@'.(string)$tmp;
}
$data[$name] = $file_value;
}
return $data;
}
/**
* Detect strings which will cause cURL to do horrible, insecure things.
*
* @param string $string Possibly dangerous string.
* @param bool $is_query_string True if this string is being used as part
* of a query string.
* @return void
*/
private function checkForDangerousCURLMagic($string, $is_query_string) {
if (empty($string[0]) || ($string[0] != '@')) {
// This isn't an "@..." string, so it's fine.
return;
}
if ($is_query_string) {
if (version_compare(phpversion(), '5.2.0', '<')) {
throw new Exception(
pht(
'Attempting to make an HTTP request, but query string data begins '.
'with "%s". Prior to PHP 5.2.0 this reads files off disk, which '.
'creates a wide attack window for security vulnerabilities. '.
'Upgrade PHP or avoid making cURL requests which begin with "%s".',
'@',
'@'));
}
// This is safe if we're on PHP 5.2.0 or newer.
return;
}
throw new Exception(
pht(
'Attempting to make an HTTP request which includes file data, but the '.
'value of a query parameter begins with "%s". PHP interprets these '.
'values to mean that it should read arbitrary files off disk and '.
'transmit them to remote servers. Declining to make this request.',
'@'));
}
/**
* Determine whether CURLOPT_CAINFO is usable on this system.
*/
private function canSetCAInfo() {
// We cannot set CAInfo on OSX after Yosemite.
$osx_version = PhutilExecutionEnvironment::getOSXVersion();
if ($osx_version) {
if (version_compare($osx_version, 14, '>=')) {
return false;
}
}
return true;
}
/**
* Write a raw HTTP body into the request.
*
* You must write the entire body before starting the request.
*
* @param string $raw_body Raw body.
- * @return this
+ * @return $this
*/
public function write($raw_body) {
$this->rawBody = $raw_body;
return $this;
}
/**
* Callback to pass data to cURL.
*/
public function willWriteBody($handle, $infile, $len) {
$bytes = substr($this->rawBody, $this->rawBodyPos, $len);
$this->rawBodyPos += $len;
return $bytes;
}
private function shouldReuseHandles() {
$curl_version = curl_version();
$version = idx($curl_version, 'version');
// NOTE: cURL 7.43.0 has a bug where the POST body length is not recomputed
// properly when a handle is reused. For this version of cURL, disable
// handle reuse and accept a small performance penalty. See T8654.
if ($version == '7.43.0') {
return false;
}
return true;
}
private function isDownload() {
return ($this->downloadPath !== null);
}
protected function getServiceProfilerStartParameters() {
return array(
'type' => 'http',
'uri' => phutil_string_cast($this->getURI()),
);
}
private function canAcceptGzip() {
return function_exists('gzdecode');
}
}
diff --git a/src/hgdaemon/ArcanistHgProxyClient.php b/src/hgdaemon/ArcanistHgProxyClient.php
index af9d8db9..d3d23779 100644
--- a/src/hgdaemon/ArcanistHgProxyClient.php
+++ b/src/hgdaemon/ArcanistHgProxyClient.php
@@ -1,201 +1,201 @@
<?php
/**
* Client for an @{class:ArcanistHgProxyServer}. This client talks to a PHP
* process which serves as a proxy in front of a Mercurial server process.
* The PHP proxy allows multiple clients to use the same Mercurial server.
*
* This class presents an API which is similar to the hg command-line API.
*
* Each client is bound to a specific working copy:
*
* $working_copy = '/path/to/some/hg/working/copy/';
* $client = new ArcanistHgProxyClient($working_copy);
*
* For example, to run `hg log -l 5` via a client:
*
* $command = array('log', '-l', '5');
* list($err, $stdout, $stderr) = $client->executeCommand($command);
*
* The advantage of using this complex mechanism is that commands run in this
* way do not need to pay the startup overhead for hg and the Python runtime,
* which is often on the order of 100ms or more per command.
*
* @task construct Construction
* @task config Configuration
* @task exec Executing Mercurial Commands
* @task internal Internals
*/
final class ArcanistHgProxyClient extends Phobject {
private $workingCopy;
private $server;
private $skipHello;
/* -( Construction )------------------------------------------------------- */
/**
* Build a new client. This client is bound to a working copy. A server
* must already be running on this working copy for the client to work.
*
* @param string $working_copy Path to a Mercurial working copy.
*
* @task construct
*/
public function __construct($working_copy) {
$this->workingCopy = Filesystem::resolvePath($working_copy);
}
/* -( Configuration )------------------------------------------------------ */
/**
* When connecting, do not expect the "capabilities" message.
*
* @param bool $skip True to skip the "capabilities" message.
- * @return this
+ * @return $this
*
* @task config
*/
public function setSkipHello($skip) {
$this->skipHello = $skip;
return $this;
}
/* -( Executing Merucurial Commands )-------------------------------------- */
/**
* Execute a command (given as a list of arguments) via the command server.
*
* @param list<string> $argv A list of command arguments, like "log", "-l",
* "5".
* @return tuple<int, string, string> Return code, stdout and stderr.
*
* @task exec
*/
public function executeCommand(array $argv) {
if (!$this->server) {
try {
$server = $this->connectToDaemon();
} catch (Exception $ex) {
$this->launchDaemon();
$server = $this->connectToDaemon();
}
$this->server = $server;
}
$server = $this->server;
// Note that we're adding "runcommand" to make the server run the command.
// Theoretically the server supports other capabilities, but in practice
// we are only concerned with "runcommand".
$server->write(array_merge(array('runcommand'), $argv));
// We'll get back one or more blocks of response data, ending with an 'r'
// block which indicates the return code. Reconstitute these into stdout,
// stderr and a return code.
$stdout = '';
$stderr = '';
$err = 0;
$done = false;
while ($message = $server->waitForMessage()) {
// The $server channel handles decoding of the wire format and gives us
// messages which look like this:
//
// array('o', '<data...>');
list($channel, $data) = $message;
switch ($channel) {
case 'o':
$stdout .= $data;
break;
case 'e':
$stderr .= $data;
break;
case 'd':
// TODO: Do something with this? This is the 'debug' channel.
break;
case 'r':
// NOTE: This little dance is because the value is emitted as a
// big-endian signed 32-bit long. PHP has no flag to unpack() that
// can unpack these, so we unpack a big-endian unsigned long, then
// repack it as a machine-order unsigned long, then unpack it as
// a machine-order signed long. This appears to produce the desired
// result.
$err = head(unpack('N', $data));
$err = pack('L', $err);
$err = head(unpack('l', $err));
$done = true;
break;
}
if ($done) {
break;
}
}
return array($err, $stdout, $stderr);
}
/* -( Internals )---------------------------------------------------------- */
/**
* @task internal
*/
private function connectToDaemon() {
$errno = null;
$errstr = null;
$socket_path = ArcanistHgProxyServer::getPathToSocket($this->workingCopy);
$socket = @stream_socket_client('unix://'.$socket_path, $errno, $errstr);
if ($errno || !$socket) {
throw new Exception(
pht(
'Unable to connect socket! Error #%d: %s',
$errno,
$errstr));
}
$channel = new PhutilSocketChannel($socket);
$server = new ArcanistHgServerChannel($channel);
if (!$this->skipHello) {
// The protocol includes a "hello" message with capability and encoding
// information. Read and discard it, we use only the "runcommand"
// capability which is guaranteed to be available.
$hello = $server->waitForMessage();
}
return $server;
}
/**
* @task internal
*/
private function launchDaemon() {
$root = dirname(phutil_get_library_root('arcanist'));
$bin = $root.'/scripts/hgdaemon/hgdaemon_server.php';
$proxy = new ExecFuture(
'%s %s --idle-limit 15 --quiet %C',
$bin,
$this->workingCopy,
$this->skipHello ? '--skip-hello' : null);
$proxy->resolvex();
}
}
diff --git a/src/hgdaemon/ArcanistHgProxyServer.php b/src/hgdaemon/ArcanistHgProxyServer.php
index b39a15bc..da7fc2b6 100644
--- a/src/hgdaemon/ArcanistHgProxyServer.php
+++ b/src/hgdaemon/ArcanistHgProxyServer.php
@@ -1,496 +1,496 @@
<?php
/**
* Server which @{class:ArcanistHgProxyClient} clients connect to. This
* server binds to a Mercurial working copy and creates a Mercurial process and
* a unix domain socket in that working copy. It listens for connections on
* the socket, reads commands from them, and forwards their requests to the
* Mercurial process. It then returns responses to the original clients.
*
* Note that this server understands the underlying protocol and completely
* decodes messages from both the client and server before re-encoding them
* and relaying them to their final destinations. It must do this (at least
* in part) to determine where messages begin and end. Additionally, this proxy
* sends and receives the Mercurial cmdserver protocol exactly, without
* any extensions or sneakiness.
*
* The advantage of this mechanism is that it avoids the overhead of starting
* a Mercurial process for each Mercurial command, which can exceed 100ms per
* invocation. This server can also accept connections from multiple clients
* and serve them from a single Mercurial server process.
*
* @task construct Construction
* @task config Configuration
* @task server Serving Requests
* @task client Managing Clients
* @task hg Managing Mercurial
* @task internal Internals
*/
final class ArcanistHgProxyServer extends Phobject {
private $workingCopy;
private $socket;
private $hello;
private $quiet;
private $clientLimit;
private $lifetimeClientCount;
private $idleLimit;
private $idleSince;
private $skipHello;
private $doNotDaemonize;
/* -( Construction )------------------------------------------------------- */
/**
* Build a new server. This server is bound to a working copy. The server
* is inactive until you @{method:start} it.
*
* @param string $working_copy Path to a Mercurial working copy.
*
* @task construct
*/
public function __construct($working_copy) {
$this->workingCopy = Filesystem::resolvePath($working_copy);
}
/* -( Configuration )------------------------------------------------------ */
/**
* Disable status messages to stdout. Controlled with `--quiet`.
*
* @param bool $quiet True to disable status messages.
- * @return this
+ * @return $this
*
* @task config
*/
public function setQuiet($quiet) {
$this->quiet = $quiet;
return $this;
}
/**
* Configure a client limit. After serving this many clients, the server
* will exit. Controlled with `--client-limit`.
*
* You can use `--client-limit 1` with `--xprofile` and `--do-not-daemonize`
* to profile the server.
*
* @param int $limit Client limit, or 0 to disable limit.
- * @return this
+ * @return $this
*
* @task config
*/
public function setClientLimit($limit) {
$this->clientLimit = $limit;
return $this;
}
/**
* Configure an idle time limit. After this many seconds idle, the server
* will exit. Controlled with `--idle-limit`.
*
* @param int $limit Idle limit, or 0 to disable limit.
- * @return this
+ * @return $this
*
* @task config
*/
public function setIdleLimit($limit) {
$this->idleLimit = $limit;
return $this;
}
/**
* When clients connect, do not send the "capabilities" message expected by
* the Mercurial protocol. This deviates from the protocol and will only work
* if the clients are also configured not to expect the message, but slightly
* improves performance. Controlled with --skip-hello.
*
* @param bool $skip True to skip the "capabilities" message.
- * @return this
+ * @return $this
*
* @task config
*/
public function setSkipHello($skip) {
$this->skipHello = $skip;
return $this;
}
/**
* Configure whether the server runs in the foreground or daemonizes.
* Controlled by --do-not-daemonize. Primarily useful for debugging.
*
* @param bool $do_not_daemonize True to run in the foreground.
- * @return this
+ * @return $this
*
* @task config
*/
public function setDoNotDaemonize($do_not_daemonize) {
$this->doNotDaemonize = $do_not_daemonize;
return $this;
}
/* -( Serving Requests )--------------------------------------------------- */
/**
* Start the server. This method returns after the client limit or idle
* limit are exceeded. If neither limit is configured, this method does not
* exit.
*
* @return null
*
* @task server
*/
public function start() {
// Create the unix domain socket in the working copy to listen for clients.
$socket = $this->startWorkingCopySocket();
$this->socket = $socket;
if (!$this->doNotDaemonize) {
$this->daemonize();
}
// Start the Mercurial process which we'll forward client requests to.
$hg = $this->startMercurialProcess();
$clients = array();
$this->log(null, pht('Listening'));
$this->idleSince = time();
while (true) {
// Wait for activity on any active clients, the Mercurial process, or
// the listening socket where new clients connect.
PhutilChannel::waitForAny(
array_merge($clients, array($hg)),
array(
'read' => $socket ? array($socket) : array(),
'except' => $socket ? array($socket) : array(),
));
if (!$hg->update()) {
throw new Exception(pht('Server exited unexpectedly!'));
}
// Accept any new clients.
while ($socket && ($client = $this->acceptNewClient($socket))) {
$clients[] = $client;
$key = last_key($clients);
$client->setName($key);
$this->log($client, pht('Connected'));
$this->idleSince = time();
// Check if we've hit the client limit. If there's a configured
// client limit and we've hit it, stop accepting new connections
// and close the socket.
$this->lifetimeClientCount++;
if ($this->clientLimit) {
if ($this->lifetimeClientCount >= $this->clientLimit) {
$this->closeSocket();
$socket = null;
}
}
}
// Update all the active clients.
foreach ($clients as $key => $client) {
if ($this->updateClient($client, $hg)) {
// In this case, the client is still connected so just move on to
// the next one. Otherwise we continue below and handle the
// disconnect.
continue;
}
$this->log($client, pht('Disconnected'));
unset($clients[$key]);
// If we have a client limit and we've served that many clients, exit.
if ($this->clientLimit) {
if ($this->lifetimeClientCount >= $this->clientLimit) {
if (!$clients) {
$this->log(null, pht('Exiting (Client Limit)'));
return;
}
}
}
}
// If we have an idle limit and haven't had any activity in at least
// that long, exit.
if ($this->idleLimit) {
$remaining = $this->idleLimit - (time() - $this->idleSince);
if ($remaining <= 0) {
$this->log(null, pht('Exiting (Idle Limit)'));
return;
}
if ($remaining <= 5) {
$this->log(null, pht('Exiting in %d seconds', $remaining));
}
}
}
}
/**
* Update one client, processing any commands it has sent us. We fully
* process all commands we've received here before returning to the main
* server loop.
*
* @param ArcanistHgClientChannel $client The client to update.
* @param ArcanistHgServerChannel $hg The Mercurial server.
*
* @task server
*/
private function updateClient(
ArcanistHgClientChannel $client,
ArcanistHgServerChannel $hg) {
if (!$client->update()) {
// Client has disconnected, don't bother proceeding.
return false;
}
// Read a command from the client if one is available. Note that we stop
// updating other clients or accepting new connections while processing a
// command, since there isn't much we can do with them until the server
// finishes executing this command.
$message = $client->read();
if (!$message) {
return true;
}
$this->log($client, '$ '.$message[0].' '.$message[1]);
$t_start = microtime(true);
// Forward the command to the server.
$hg->write($message);
while (true) {
PhutilChannel::waitForAny(array($client, $hg));
if (!$client->update() || !$hg->update()) {
// If either the client or server has exited, bail.
return false;
}
$response = $hg->read();
if (!$response) {
continue;
}
// Forward the response back to the client.
$client->write($response);
// If the response was on the 'r'esult channel, it indicates the end
// of the command output. We can process the next command (if any
// remain) or go back to accepting new connections and servicing
// other clients.
if ($response[0] == 'r') {
// Update the client immediately to try to get the bytes on the wire
// as quickly as possible. This gives us slightly more throughput.
$client->update();
break;
}
}
// Log the elapsed time.
$t_end = microtime(true);
$t = 1000000 * ($t_end - $t_start);
$this->log($client, pht('< %sus', number_format($t, 0)));
$this->idleSince = time();
return true;
}
/* -( Managing Clients )--------------------------------------------------- */
/**
* @task client
*/
public static function getPathToSocket($working_copy) {
return $working_copy.'/.hg/hgdaemon-socket';
}
/**
* @task client
*/
private function startWorkingCopySocket() {
$errno = null;
$errstr = null;
$socket_path = self::getPathToSocket($this->workingCopy);
$socket_uri = 'unix://'.$socket_path;
$socket = @stream_socket_server($socket_uri, $errno, $errstr);
if ($errno || !$socket) {
Filesystem::remove($socket_path);
$socket = @stream_socket_server($socket_uri, $errno, $errstr);
}
if ($errno || !$socket) {
throw new Exception(
pht(
'Unable to start socket! Error #%d: %s',
$errno,
$errstr));
}
$ok = stream_set_blocking($socket, 0);
if ($ok === false) {
throw new Exception(pht('Unable to set socket nonblocking!'));
}
return $socket;
}
/**
* @task client
*/
private function acceptNewClient($socket) {
// NOTE: stream_socket_accept() always blocks, even when the socket has
// been set nonblocking.
$new_client = @stream_socket_accept($socket, $timeout = 0);
if (!$new_client) {
return null;
}
$channel = new PhutilSocketChannel($new_client);
$client = new ArcanistHgClientChannel($channel);
if (!$this->skipHello) {
$client->write($this->hello);
}
return $client;
}
/* -( Managing Mercurial )------------------------------------------------- */
/**
* Starts a Mercurial process which can actually handle requests.
*
* @return ArcanistHgServerChannel Channel to the Mercurial server.
* @task hg
*/
private function startMercurialProcess() {
// NOTE: "cmdserver.log=-" makes Mercurial use the 'd'ebug channel for
// log messages.
$future = new ExecFuture(
'HGPLAIN=1 hg --config cmdserver.log=- serve --cmdserver pipe');
$future->setCWD($this->workingCopy);
$channel = new PhutilExecChannel($future);
$hg = new ArcanistHgServerChannel($channel);
// The server sends a "hello" message with capability and encoding
// information. Save it and forward it to clients when they connect.
$this->hello = $hg->waitForMessage();
return $hg;
}
/* -( Internals )---------------------------------------------------------- */
/**
* Close and remove the unix domain socket in the working copy.
*
* @task internal
*/
public function __destruct() {
$this->closeSocket();
}
private function closeSocket() {
if ($this->socket) {
@stream_socket_shutdown($this->socket, STREAM_SHUT_RDWR);
@fclose($this->socket);
Filesystem::remove(self::getPathToSocket($this->workingCopy));
$this->socket = null;
}
}
private function log($client, $message) {
if ($this->quiet) {
return;
}
if ($client) {
$message = sprintf(
'[%s] %s',
pht('Client %s', $client->getName()),
$message);
} else {
$message = sprintf(
'[%s] %s',
pht('Server'),
$message);
}
echo $message."\n";
}
private function daemonize() {
// Keep stdout if it's been redirected somewhere, otherwise shut it down.
$keep_stdout = false;
$keep_stderr = false;
if (function_exists('posix_isatty')) {
if (!posix_isatty(STDOUT)) {
$keep_stdout = true;
}
if (!posix_isatty(STDERR)) {
$keep_stderr = true;
}
}
$pid = pcntl_fork();
if ($pid === -1) {
throw new Exception(pht('Unable to fork!'));
} else if ($pid) {
// We're the parent; exit. First, drop our reference to the socket so
// our __destruct() doesn't tear it down; the child will tear it down
// later.
$this->socket = null;
exit(0);
}
// We're the child; continue.
fclose(STDIN);
if (!$keep_stdout) {
fclose(STDOUT);
$this->quiet = true;
}
if (!$keep_stderr) {
fclose(STDERR);
}
}
}
diff --git a/src/lint/engine/ArcanistLintEngine.php b/src/lint/engine/ArcanistLintEngine.php
index 1ea5b84c..9d38e9e7 100644
--- a/src/lint/engine/ArcanistLintEngine.php
+++ b/src/lint/engine/ArcanistLintEngine.php
@@ -1,619 +1,619 @@
<?php
/**
* Manages lint execution. When you run 'arc lint' or 'arc diff', Arcanist
* attempts to run lint rules using a lint engine.
*
* Lint engines are high-level strategic classes which do not contain any
* actual linting rules. Linting rules live in `Linter` classes. The lint
* engine builds and configures linters.
*
* Most modern linters can be configured with an `.arclint` file, which is
* managed by the builtin @{class:ArcanistConfigurationDrivenLintEngine}.
* Consult the documentation for more information on these files.
*
* In the majority of cases, you do not need to write a custom lint engine.
* For example, to add new rules for a new language, write a linter instead.
* However, if you have a very advanced or specialized use case, you can write
* a custom lint engine by extending this class; custom lint engines are more
* powerful but much more complex than the builtin engines.
*
* The lint engine is given a list of paths (generally, the paths that you
* modified in your change) and determines which linters to run on them. The
* linters themselves are responsible for actually analyzing file text and
* finding warnings and errors. For example, if the modified paths include some
* JS files and some Python files, you might want to run JSLint on the JS files
* and PyLint on the Python files.
*
* You can also run multiple linters on a single file. For instance, you might
* run one linter on all text files to make sure they don't have trailing
* whitespace, or enforce tab vs space rules, or make sure there are enough
* curse words in them.
*
* You can test an engine like this:
*
* arc lint --engine YourLintEngineClassName --lintall some_file.py
*
* ...which will show you all the lint issues raised in the file.
*
* See @{article@phabricator:Arcanist User Guide: Customizing Lint, Unit Tests
* and Workflows} for more information about configuring lint engines.
*/
abstract class ArcanistLintEngine extends Phobject {
protected $workingCopy;
protected $paths = array();
protected $fileData = array();
protected $charToLine = array();
protected $lineToFirstChar = array();
private $cachedResults;
private $cacheVersion;
private $repositoryVersion;
private $results = array();
private $stopped = array();
private $minimumSeverity = ArcanistLintSeverity::SEVERITY_DISABLED;
private $changedLines = array();
private $configurationManager;
private $linterResources = array();
public function __construct() {}
final public function setConfigurationManager(
ArcanistConfigurationManager $configuration_manager) {
$this->configurationManager = $configuration_manager;
return $this;
}
final public function getConfigurationManager() {
return $this->configurationManager;
}
final public function setWorkingCopy(
ArcanistWorkingCopyIdentity $working_copy) {
$this->workingCopy = $working_copy;
return $this;
}
final public function getWorkingCopy() {
return $this->workingCopy;
}
final public function setPaths($paths) {
$this->paths = $paths;
return $this;
}
public function getPaths() {
return $this->paths;
}
final public function setPathChangedLines($path, $changed) {
if ($changed === null) {
$this->changedLines[$path] = null;
} else {
$this->changedLines[$path] = array_fill_keys($changed, true);
}
return $this;
}
final public function getPathChangedLines($path) {
return idx($this->changedLines, $path);
}
final public function setFileData($data) {
$this->fileData = $data + $this->fileData;
return $this;
}
final public function loadData($path) {
if (!isset($this->fileData[$path])) {
$disk_path = $this->getFilePathOnDisk($path);
$this->fileData[$path] = Filesystem::readFile($disk_path);
}
return $this->fileData[$path];
}
public function pathExists($path) {
$disk_path = $this->getFilePathOnDisk($path);
return Filesystem::pathExists($disk_path);
}
final public function isDirectory($path) {
$disk_path = $this->getFilePathOnDisk($path);
return is_dir($disk_path);
}
final public function isBinaryFile($path) {
try {
$data = $this->loadData($path);
} catch (Exception $ex) {
return false;
}
return ArcanistDiffUtils::isHeuristicBinaryFile($data);
}
final public function isSymbolicLink($path) {
return is_link($this->getFilePathOnDisk($path));
}
final public function getFilePathOnDisk($path) {
return Filesystem::resolvePath(
$path,
$this->getWorkingCopy()->getProjectRoot());
}
final public function setMinimumSeverity($severity) {
$this->minimumSeverity = $severity;
return $this;
}
final public function run() {
$linters = $this->buildLinters();
if (!$linters) {
throw new ArcanistNoEffectException(pht('No linters to run.'));
}
foreach ($linters as $key => $linter) {
$linter->setLinterID($key);
}
$linters = msort($linters, 'getLinterPriority');
foreach ($linters as $linter) {
$linter->setEngine($this);
}
$have_paths = false;
foreach ($linters as $linter) {
if ($linter->getPaths()) {
$have_paths = true;
break;
}
}
if (!$have_paths) {
throw new ArcanistNoEffectException(pht('No paths are lintable.'));
}
$versions = array($this->getCacheVersion());
foreach ($linters as $linter) {
$version = get_class($linter).':'.$linter->getCacheVersion();
$symbols = id(new PhutilSymbolLoader())
->setType('class')
->setName(get_class($linter))
->selectSymbolsWithoutLoading();
$symbol = idx($symbols, 'class$'.get_class($linter));
if ($symbol) {
$version .= ':'.md5_file(
phutil_get_library_root($symbol['library']).'/'.$symbol['where']);
}
$versions[] = $version;
}
$this->cacheVersion = crc32(implode("\n", $versions));
$runnable = $this->getRunnableLinters($linters);
$this->stopped = array();
$exceptions = $this->executeLinters($runnable);
foreach ($runnable as $linter) {
foreach ($linter->getLintMessages() as $message) {
$this->validateLintMessage($linter, $message);
if (!$this->isSeverityEnabled($message->getSeverity())) {
continue;
}
if (!$this->isRelevantMessage($message)) {
continue;
}
$message->setGranularity($linter->getCacheGranularity());
$result = $this->getResultForPath($message->getPath());
$result->addMessage($message);
}
}
if ($this->cachedResults) {
foreach ($this->cachedResults as $path => $messages) {
$messages = idx($messages, $this->cacheVersion, array());
$repository_version = idx($messages, 'repository_version');
unset($messages['stopped']);
unset($messages['repository_version']);
foreach ($messages as $message) {
$use_cache = $this->shouldUseCache(
idx($message, 'granularity'),
$repository_version);
if ($use_cache) {
$this->getResultForPath($path)->addMessage(
ArcanistLintMessage::newFromDictionary($message));
}
}
}
}
foreach ($this->results as $path => $result) {
$disk_path = $this->getFilePathOnDisk($path);
$result->setFilePathOnDisk($disk_path);
if (isset($this->fileData[$path])) {
$result->setData($this->fileData[$path]);
} else if ($disk_path && Filesystem::pathExists($disk_path)) {
// TODO: this may cause us to, e.g., load a large binary when we only
// raised an error about its filename. We could refine this by looking
// through the lint messages and doing this load only if any of them
// have original/replacement text or something like that.
try {
$this->fileData[$path] = Filesystem::readFile($disk_path);
$result->setData($this->fileData[$path]);
} catch (FilesystemException $ex) {
// Ignore this, it's noncritical that we access this data and it
// might be unreadable or a directory or whatever else for plenty
// of legitimate reasons.
}
}
}
if ($exceptions) {
throw new PhutilAggregateException(
pht('Some linters failed:'),
$exceptions);
}
return $this->results;
}
final public function isSeverityEnabled($severity) {
$minimum = $this->minimumSeverity;
return ArcanistLintSeverity::isAtLeastAsSevere($severity, $minimum);
}
private function shouldUseCache(
$cache_granularity,
$repository_version) {
switch ($cache_granularity) {
case ArcanistLinter::GRANULARITY_FILE:
return true;
case ArcanistLinter::GRANULARITY_DIRECTORY:
case ArcanistLinter::GRANULARITY_REPOSITORY:
return ($this->repositoryVersion == $repository_version);
default:
return false;
}
}
/**
* @param dict<string path, dict<string version, list<dict message>>>
* $results
- * @return this
+ * @return $this
*/
final public function setCachedResults(array $results) {
$this->cachedResults = $results;
return $this;
}
final public function getResults() {
return $this->results;
}
final public function getStoppedPaths() {
return $this->stopped;
}
abstract public function buildLinters();
final public function setRepositoryVersion($version) {
$this->repositoryVersion = $version;
return $this;
}
private function isRelevantMessage(ArcanistLintMessage $message) {
// When a user runs "arc lint", we default to raising only warnings on
// lines they have changed (errors are still raised anywhere in the
// file). The list of $changed lines may be null, to indicate that the
// path is a directory or a binary file so we should not exclude
// warnings.
if (!$this->changedLines ||
$message->isError() ||
$message->shouldBypassChangedLineFiltering()) {
return true;
}
$locations = $message->getOtherLocations();
$locations[] = $message->toDictionary();
foreach ($locations as $location) {
$path = idx($location, 'path', $message->getPath());
if (!array_key_exists($path, $this->changedLines)) {
if (phutil_is_windows()) {
// We try checking the UNIX path form as well, on Windows. Linters
// store noramlized paths, which use the Windows-style "\" as a
// delimiter; as such, they don't match the UNIX-style paths stored
// in changedLines, which come from the VCS.
$path = str_replace('\\', '/', $path);
if (!array_key_exists($path, $this->changedLines)) {
continue;
}
} else {
continue;
}
}
$changed = $this->getPathChangedLines($path);
if ($changed === null || !$location['line']) {
return true;
}
$last_line = $location['line'];
if (isset($location['original'])) {
$last_line += substr_count($location['original'], "\n");
}
for ($l = $location['line']; $l <= $last_line; $l++) {
if (!empty($changed[$l])) {
return true;
}
}
}
return false;
}
final protected function getResultForPath($path) {
if (empty($this->results[$path])) {
$result = new ArcanistLintResult();
$result->setPath($path);
$result->setCacheVersion($this->cacheVersion);
$this->results[$path] = $result;
}
return $this->results[$path];
}
final public function getLineAndCharFromOffset($path, $offset) {
if (!isset($this->charToLine[$path])) {
$char_to_line = array();
$line_to_first_char = array();
$lines = explode("\n", $this->loadData($path));
$line_number = 0;
$line_start = 0;
foreach ($lines as $line) {
$len = strlen($line) + 1; // Account for "\n".
$line_to_first_char[] = $line_start;
$line_start += $len;
for ($ii = 0; $ii < $len; $ii++) {
$char_to_line[] = $line_number;
}
$line_number++;
}
$this->charToLine[$path] = $char_to_line;
$this->lineToFirstChar[$path] = $line_to_first_char;
}
$line = $this->charToLine[$path][$offset];
$char = $offset - $this->lineToFirstChar[$path][$line];
return array($line, $char);
}
protected function getCacheVersion() {
return 1;
}
/**
* Get a named linter resource shared by another linter.
*
* This mechanism allows linters to share arbitrary resources, like the
* results of computation. If several linters need to perform the same
* expensive computation step, they can use a named resource to synchronize
* construction of the result so it doesn't need to be built multiple
* times.
*
* @param string $key Resource identifier.
* @param wild $default (optional) Default value to return if resource
* does not exist.
* @return wild Resource, or default value if not present.
*/
public function getLinterResource($key, $default = null) {
return idx($this->linterResources, $key, $default);
}
/**
* Set a linter resource that other linters can access.
*
* See @{method:getLinterResource} for a description of this mechanism.
*
* @param string $key Resource identifier.
* @param wild $value Resource.
- * @return this
+ * @return $this
*/
public function setLinterResource($key, $value) {
$this->linterResources[$key] = $value;
return $this;
}
private function getRunnableLinters(array $linters) {
assert_instances_of($linters, 'ArcanistLinter');
// TODO: The canRun() mechanism is only used by one linter, and just
// silently disables the linter. Almost every other linter handles this
// by throwing `ArcanistMissingLinterException`. Both mechanisms are not
// ideal; linters which can not run should emit a message, get marked as
// "skipped", and allow execution to continue. See T7045.
$runnable = array();
foreach ($linters as $key => $linter) {
if ($linter->canRun()) {
$runnable[$key] = $linter;
}
}
return $runnable;
}
private function executeLinters(array $runnable) {
assert_instances_of($runnable, 'ArcanistLinter');
$all_paths = $this->getPaths();
$path_chunks = array_chunk($all_paths, 32, $preserve_keys = true);
$exception_lists = array();
foreach ($path_chunks as $chunk) {
$exception_lists[] = $this->executeLintersOnChunk($runnable, $chunk);
}
return array_mergev($exception_lists);
}
private function executeLintersOnChunk(array $runnable, array $path_list) {
assert_instances_of($runnable, 'ArcanistLinter');
$path_map = array_fuse($path_list);
$exceptions = array();
$did_lint = array();
foreach ($runnable as $linter) {
$linter_id = $linter->getLinterID();
$paths = $linter->getPaths();
foreach ($paths as $key => $path) {
// If we aren't running this path in the current chunk of paths,
// skip it completely.
if (empty($path_map[$path])) {
unset($paths[$key]);
continue;
}
// Make sure each path has a result generated, even if it is empty
// (i.e., the file has no lint messages).
$result = $this->getResultForPath($path);
// If a linter has stopped all other linters for this path, don't
// actually run the linter.
if (isset($this->stopped[$path])) {
unset($paths[$key]);
continue;
}
// If we have a cached result for this path, don't actually run the
// linter.
if (isset($this->cachedResults[$path][$this->cacheVersion])) {
$cached_result = $this->cachedResults[$path][$this->cacheVersion];
$use_cache = $this->shouldUseCache(
$linter->getCacheGranularity(),
idx($cached_result, 'repository_version'));
if ($use_cache) {
unset($paths[$key]);
if (idx($cached_result, 'stopped') == $linter_id) {
$this->stopped[$path] = $linter_id;
}
}
}
}
$paths = array_values($paths);
if (!$paths) {
continue;
}
try {
$this->executeLinterOnPaths($linter, $paths);
$did_lint[] = array($linter, $paths);
} catch (Exception $ex) {
$exceptions[] = $ex;
}
}
foreach ($did_lint as $info) {
list($linter, $paths) = $info;
try {
$this->executeDidLintOnPaths($linter, $paths);
} catch (Exception $ex) {
$exceptions[] = $ex;
}
}
return $exceptions;
}
private function beginLintServiceCall(ArcanistLinter $linter, array $paths) {
$profiler = PhutilServiceProfiler::getInstance();
return $profiler->beginServiceCall(
array(
'type' => 'lint',
'linter' => $linter->getInfoName(),
'paths' => $paths,
));
}
private function endLintServiceCall($call_id) {
$profiler = PhutilServiceProfiler::getInstance();
$profiler->endServiceCall($call_id, array());
}
private function executeLinterOnPaths(ArcanistLinter $linter, array $paths) {
$call_id = $this->beginLintServiceCall($linter, $paths);
try {
$linter->willLintPaths($paths);
foreach ($paths as $path) {
$linter->setActivePath($path);
$linter->lintPath($path);
if ($linter->didStopAllLinters()) {
$this->stopped[$path] = $linter->getLinterID();
}
}
} catch (Exception $ex) {
$this->endLintServiceCall($call_id);
throw $ex;
}
$this->endLintServiceCall($call_id);
}
private function executeDidLintOnPaths(ArcanistLinter $linter, array $paths) {
$call_id = $this->beginLintServiceCall($linter, $paths);
try {
$linter->didLintPaths($paths);
} catch (Exception $ex) {
$this->endLintServiceCall($call_id);
throw $ex;
}
$this->endLintServiceCall($call_id);
}
private function validateLintMessage(
ArcanistLinter $linter,
ArcanistLintMessage $message) {
$name = $message->getName();
if (!strlen($name)) {
throw new Exception(
pht(
'Linter "%s" generated a lint message that is invalid because it '.
'does not have a name. Lint messages must have a name.',
get_class($linter)));
}
}
}
diff --git a/src/lint/linter/ArcanistExternalLinter.php b/src/lint/linter/ArcanistExternalLinter.php
index 24e1c09f..51877e2b 100644
--- a/src/lint/linter/ArcanistExternalLinter.php
+++ b/src/lint/linter/ArcanistExternalLinter.php
@@ -1,582 +1,582 @@
<?php
/**
* Base class for linters which operate by invoking an external program and
* parsing results.
*
* @task bin Interpreters, Binaries and Flags
* @task parse Parsing Linter Output
* @task exec Executing the Linter
*/
abstract class ArcanistExternalLinter extends ArcanistFutureLinter {
private $bin;
private $interpreter;
private $flags;
private $versionRequirement;
/* -( Interpreters, Binaries and Flags )----------------------------------- */
/**
* Return the default binary name or binary path where the external linter
* lives. This can either be a binary name which is expected to be installed
* in PATH (like "jshint"), or a relative path from the project root
* (like "resources/support/bin/linter") or an absolute path.
*
* If the binary needs an interpreter (like "python" or "node"), you should
* also override @{method:shouldUseInterpreter} and provide the interpreter
* in @{method:getDefaultInterpreter}.
*
* @return string Default binary to execute.
* @task bin
*/
abstract public function getDefaultBinary();
/**
* Return a human-readable string describing how to install the linter. This
* is normally something like "Install such-and-such by running `npm install
* -g such-and-such`.", but will differ from linter to linter.
*
* @return string Human readable install instructions
* @task bin
*/
abstract public function getInstallInstructions();
/**
* Return a human-readable string describing how to upgrade the linter.
*
* @return string Human readable upgrade instructions
* @task bin
*/
public function getUpgradeInstructions() {
return null;
}
/**
* Return true to continue when the external linter exits with an error code.
* By default, linters which exit with an error code are assumed to have
* failed. However, some linters exit with a specific code to indicate that
* lint messages were detected.
*
* If the linter sometimes raises errors during normal operation, override
* this method and return true so execution continues when it exits with
* a nonzero status.
*
* @param bool Return true to continue on nonzero error code.
* @task bin
*/
public function shouldExpectCommandErrors() {
return true;
}
/**
* Provide mandatory, non-overridable flags to the linter. Generally these
* are format flags, like `--format=xml`, which must always be given for
* the output to be usable.
*
* Flags which are not mandatory should be provided in
* @{method:getDefaultFlags} instead.
*
* @return list<string> Mandatory flags, like `"--format=xml"`.
* @task bin
*/
protected function getMandatoryFlags() {
return array();
}
/**
* Provide default, overridable flags to the linter. Generally these are
* configuration flags which affect behavior but aren't critical. Flags
* which are required should be provided in @{method:getMandatoryFlags}
* instead.
*
* Default flags can be overridden with @{method:setFlags}.
*
* @return list<string> Overridable default flags.
* @task bin
*/
protected function getDefaultFlags() {
return array();
}
/**
* Override default flags with custom flags. If not overridden, flags provided
* by @{method:getDefaultFlags} are used.
*
* @param list<string> $flags New flags.
- * @return this
+ * @return $this
* @task bin
*/
final public function setFlags(array $flags) {
$this->flags = $flags;
return $this;
}
/**
* Set the binary's version requirement.
*
* @param string $version Version requirement.
- * @return this
+ * @return $this
* @task bin
*/
final public function setVersionRequirement($version) {
$this->versionRequirement = trim($version);
return $this;
}
/**
* Return the binary or script to execute. This method synthesizes defaults
* and configuration. You can override the binary with @{method:setBinary}.
*
* @return string Binary to execute.
* @task bin
*/
final public function getBinary() {
$bin = coalesce($this->bin, $this->getDefaultBinary());
if (phutil_is_windows()) {
// On Windows, we use proc_open with 'bypass_shell' option, which will
// resolve %PATH%, but not %PATHEXT% (unless the extension is .exe).
// Therefore find the right binary ourselves.
// If we can't find it, leave it unresolved, as this string will be
// used in some error messages elsewhere.
$resolved = Filesystem::resolveBinary($bin);
if ($resolved) {
return $resolved;
}
}
return $bin;
}
/**
* Override the default binary with a new one.
*
* @param string $bin New binary.
- * @return this
+ * @return $this
* @task bin
*/
final public function setBinary($bin) {
$this->bin = $bin;
return $this;
}
/**
* Return true if this linter should use an interpreter (like "python" or
* "node") in addition to the script.
*
* After overriding this method to return `true`, override
* @{method:getDefaultInterpreter} to set a default.
*
* @return bool True to use an interpreter.
* @task bin
*/
public function shouldUseInterpreter() {
return false;
}
/**
* Return the default interpreter, like "python" or "node". This method is
* only invoked if @{method:shouldUseInterpreter} has been overridden to
* return `true`.
*
* @return string Default interpreter.
* @task bin
*/
public function getDefaultInterpreter() {
throw new PhutilMethodNotImplementedException();
}
/**
* Get the effective interpreter. This method synthesizes configuration and
* defaults.
*
* @return string Effective interpreter.
* @task bin
*/
final public function getInterpreter() {
return coalesce($this->interpreter, $this->getDefaultInterpreter());
}
/**
* Set the interpreter, overriding any default.
*
* @param string $interpreter New interpreter.
- * @return this
+ * @return $this
* @task bin
*/
final public function setInterpreter($interpreter) {
$this->interpreter = $interpreter;
return $this;
}
/* -( Parsing Linter Output )---------------------------------------------- */
/**
* Parse the output of the external lint program into objects of class
* @{class:ArcanistLintMessage} which `arc` can consume. Generally, this
* means examining the output and converting each warning or error into a
* message.
*
* If parsing fails, returning `false` will cause the caller to throw an
* appropriate exception. (You can also throw a more specific exception if
* you're able to detect a more specific condition.) Otherwise, return a list
* of messages.
*
* @param string $path Path to the file being linted.
* @param int $err Exit code of the linter.
* @param string $stdout Stdout of the linter.
* @param string $stderr Stderr of the linter.
* @return list<ArcanistLintMessage>|false List of lint messages, or false
* to indicate parser failure.
* @task parse
*/
abstract protected function parseLinterOutput($path, $err, $stdout, $stderr);
/* -( Executing the Linter )----------------------------------------------- */
/**
* Check that the binary and interpreter (if applicable) exist, and throw
* an exception with a message about how to install them if they do not.
*
* @return void
*/
final public function checkBinaryConfiguration() {
$interpreter = null;
if ($this->shouldUseInterpreter()) {
$interpreter = $this->getInterpreter();
}
$binary = $this->getBinary();
// NOTE: If we have an interpreter, we don't require the script to be
// executable (so we just check that the path exists). Otherwise, the
// binary must be executable.
if ($interpreter) {
if (!Filesystem::binaryExists($interpreter)) {
throw new ArcanistMissingLinterException(
pht(
'Unable to locate interpreter "%s" to run linter %s. You may need '.
'to install the interpreter, or adjust your linter configuration.',
$interpreter,
get_class($this)));
}
if (!Filesystem::pathExists($binary)) {
throw new ArcanistMissingLinterException(
sprintf(
"%s\n%s",
pht(
'Unable to locate script "%s" to run linter %s. You may need '.
'to install the script, or adjust your linter configuration.',
$binary,
get_class($this)),
pht(
'TO INSTALL: %s',
$this->getInstallInstructions())));
}
} else {
if (!Filesystem::binaryExists($binary)) {
throw new ArcanistMissingLinterException(
sprintf(
"%s\n%s",
pht(
'Unable to locate binary "%s" to run linter %s. You may need '.
'to install the binary, or adjust your linter configuration.',
$binary,
get_class($this)),
pht(
'TO INSTALL: %s',
$this->getInstallInstructions())));
}
}
}
/**
* If a binary version requirement has been specified, compare the version
* of the configured binary to the required version, and if the binary's
* version is not supported, throw an exception.
*
* @param string $version Version string to check.
* @return void
*/
final protected function checkBinaryVersion($version) {
if (!$this->versionRequirement) {
return;
}
if (!$version) {
$message = pht(
'Linter %s requires %s version %s. Unable to determine the version '.
'that you have installed.',
get_class($this),
$this->getBinary(),
$this->versionRequirement);
$instructions = $this->getUpgradeInstructions();
if ($instructions) {
$message .= "\n".pht('TO UPGRADE: %s', $instructions);
}
throw new ArcanistMissingLinterException($message);
}
$operator = '==';
$compare_to = $this->versionRequirement;
$matches = null;
if (preg_match('/^([<>]=?|=)\s*(.*)$/', $compare_to, $matches)) {
$operator = $matches[1];
$compare_to = $matches[2];
if ($operator === '=') {
$operator = '==';
}
}
if (!version_compare($version, $compare_to, $operator)) {
$message = pht(
'Linter %s requires %s version %s. You have version %s.',
get_class($this),
$this->getBinary(),
$this->versionRequirement,
$version);
$instructions = $this->getUpgradeInstructions();
if ($instructions) {
$message .= "\n".pht('TO UPGRADE: %s', $instructions);
}
throw new ArcanistMissingLinterException($message);
}
}
/**
* Get the composed executable command, including the interpreter and binary
* but without flags or paths. This can be used to execute `--version`
* commands.
*
* @return string Command to execute the raw linter.
* @task exec
*/
final protected function getExecutableCommand() {
$this->checkBinaryConfiguration();
$interpreter = null;
if ($this->shouldUseInterpreter()) {
$interpreter = $this->getInterpreter();
}
$binary = $this->getBinary();
if ($interpreter) {
$bin = csprintf('%s %s', $interpreter, $binary);
} else {
$bin = csprintf('%s', $binary);
}
return $bin;
}
/**
* Get the composed flags for the executable, including both mandatory and
* configured flags.
*
* @return list<string> Composed flags.
* @task exec
*/
final protected function getCommandFlags() {
return array_merge(
$this->getMandatoryFlags(),
nonempty($this->flags, $this->getDefaultFlags()));
}
public function getCacheVersion() {
try {
$this->checkBinaryConfiguration();
} catch (ArcanistMissingLinterException $e) {
return null;
}
$version = $this->getVersion();
if ($version) {
$this->checkBinaryVersion($version);
return $version.'-'.json_encode($this->getCommandFlags());
} else {
// Either we failed to parse the version number or the `getVersion`
// function hasn't been implemented.
return json_encode($this->getCommandFlags());
}
}
/**
* Prepare the path to be added to the command string.
*
* This method is expected to return an already escaped string.
*
* @param string $path Path to the file being linted
* @return string The command-ready file argument
*/
protected function getPathArgumentForLinterFuture($path) {
return csprintf('%s', $path);
}
protected function buildFutures(array $paths) {
$executable = $this->getExecutableCommand();
$bin = csprintf('%C %Ls', $executable, $this->getCommandFlags());
$futures = array();
foreach ($paths as $path) {
$disk_path = $this->getEngine()->getFilePathOnDisk($path);
$path_argument = $this->getPathArgumentForLinterFuture($disk_path);
$future = new ExecFuture('%C %C', $bin, $path_argument);
$future->setCWD($this->getProjectRoot());
$futures[$path] = $future;
}
return $futures;
}
protected function resolveFuture($path, Future $future) {
list($err, $stdout, $stderr) = $future->resolve();
if ($err && !$this->shouldExpectCommandErrors()) {
$future->resolvex();
}
$messages = $this->parseLinterOutput($path, $err, $stdout, $stderr);
if ($err && $this->shouldExpectCommandErrors() && !$messages) {
// We assume that if the future exits with a non-zero status and we
// failed to parse any linter messages, then something must've gone wrong
// during parsing.
$messages = false;
}
if ($messages === false) {
if ($err) {
$future->resolvex();
} else {
throw new Exception(
sprintf(
"%s\n\nSTDOUT\n%s\n\nSTDERR\n%s",
pht('Linter failed to parse output!'),
$stdout,
$stderr));
}
}
foreach ($messages as $message) {
$this->addLintMessage($message);
}
}
public function getLinterConfigurationOptions() {
$options = array(
'bin' => array(
'type' => 'optional string | list<string>',
'help' => pht(
'Specify a string (or list of strings) identifying the binary '.
'which should be invoked to execute this linter. This overrides '.
'the default binary. If you provide a list of possible binaries, '.
'the first one which exists will be used.'),
),
'flags' => array(
'type' => 'optional list<string>',
'help' => pht(
'Provide a list of additional flags to pass to the linter on the '.
'command line.'),
),
'version' => array(
'type' => 'optional string',
'help' => pht(
'Specify a version requirement for the binary. The version number '.
'may be prefixed with <, <=, >, >=, or = to specify the version '.
'comparison operator (default: =).'),
),
);
if ($this->shouldUseInterpreter()) {
$options['interpreter'] = array(
'type' => 'optional string | list<string>',
'help' => pht(
'Specify a string (or list of strings) identifying the interpreter '.
'which should be used to invoke the linter binary. If you provide '.
'a list of possible interpreters, the first one that exists '.
'will be used.'),
);
}
return $options + parent::getLinterConfigurationOptions();
}
public function setLinterConfigurationValue($key, $value) {
switch ($key) {
case 'interpreter':
$root = $this->getProjectRoot();
foreach ((array)$value as $path) {
if (Filesystem::binaryExists($path)) {
$this->setInterpreter($path);
return;
}
$path = Filesystem::resolvePath($path, $root);
if (Filesystem::binaryExists($path)) {
$this->setInterpreter($path);
return;
}
}
throw new Exception(
pht('None of the configured interpreters can be located.'));
case 'bin':
$is_script = $this->shouldUseInterpreter();
$root = $this->getProjectRoot();
foreach ((array)$value as $path) {
if (!$is_script && Filesystem::binaryExists($path)) {
$this->setBinary($path);
return;
}
$path = Filesystem::resolvePath($path, $root);
if ((!$is_script && Filesystem::binaryExists($path)) ||
($is_script && Filesystem::pathExists($path))) {
$this->setBinary($path);
return;
}
}
throw new Exception(
pht('None of the configured binaries can be located.'));
case 'flags':
$this->setFlags($value);
return;
case 'version':
$this->setVersionRequirement($value);
return;
}
return parent::setLinterConfigurationValue($key, $value);
}
/**
* Map a configuration lint code to an `arc` lint code. Primarily, this is
* intended for validation, but can also be used to normalize case or
* otherwise be more permissive in accepted inputs.
*
* If the code is not recognized, you should throw an exception.
*
* @param string $code Code specified in configuration.
* @return string Normalized code to use in severity map.
*/
protected function getLintCodeFromLinterConfigurationKey($code) {
return $code;
}
}
diff --git a/src/lint/linter/ArcanistLinter.php b/src/lint/linter/ArcanistLinter.php
index 4fc50861..843a9e40 100644
--- a/src/lint/linter/ArcanistLinter.php
+++ b/src/lint/linter/ArcanistLinter.php
@@ -1,627 +1,627 @@
<?php
/**
* Implements lint rules, like syntax checks for a specific language.
*
* @task info Human Readable Information
* @task state Runtime State
* @task exec Executing Linters
*/
abstract class ArcanistLinter extends Phobject {
const GRANULARITY_FILE = 1;
const GRANULARITY_DIRECTORY = 2;
const GRANULARITY_REPOSITORY = 3;
const GRANULARITY_GLOBAL = 4;
private $id;
protected $paths = array();
private $filteredPaths = null;
protected $data = array();
protected $engine;
protected $activePath;
protected $messages = array();
protected $stopAllLinters = false;
private $customSeverityMap = array();
private $customSeverityRules = array();
/* -( Human Readable Information )----------------------------------------- */
/**
* Return an optional informative URI where humans can learn more about this
* linter.
*
* For most linters, this should return a link to the project home page. This
* is shown on `arc linters`.
*
* @return string|null Optionally, return an informative URI.
* @task info
*/
public function getInfoURI() {
return null;
}
/**
* Return a brief human-readable description of the linter.
*
* These should be a line or two, and are shown on `arc linters`.
*
* @return string|null Optionally, return a brief human-readable description.
* @task info
*/
public function getInfoDescription() {
return null;
}
/**
* Return arbitrary additional information.
*
* Linters can use this method to provide arbitrary additional information to
* be included in the output of `arc linters`.
*
* @return map<string, string> A mapping of header to body content for the
* additional information sections.
* @task info
*/
public function getAdditionalInformation() {
return array();
}
/**
* Return a human-readable linter name.
*
* These are used by `arc linters`, and can let you give a linter a more
* presentable name.
*
* @return string Human-readable linter name.
* @task info
*/
public function getInfoName() {
return nonempty(
$this->getLinterName(),
$this->getLinterConfigurationName(),
get_class($this));
}
/* -( Runtime State )------------------------------------------------------ */
/**
* @task state
*/
final public function getActivePath() {
return $this->activePath;
}
/**
* @task state
*/
final public function setActivePath($path) {
$this->stopAllLinters = false;
$this->activePath = $path;
return $this;
}
/**
* @task state
*/
final public function setEngine(ArcanistLintEngine $engine) {
$this->engine = $engine;
return $this;
}
/**
* @task state
*/
final protected function getEngine() {
return $this->engine;
}
/**
* Set the internal ID for this linter.
*
* This ID is assigned automatically by the @{class:ArcanistLintEngine}.
*
* @param string $id Unique linter ID.
- * @return this
+ * @return $this
* @task state
*/
final public function setLinterID($id) {
$this->id = $id;
return $this;
}
/**
* Get the internal ID for this linter.
*
* Retrieves an internal linter ID managed by the @{class:ArcanistLintEngine}.
* This ID is a unique scalar which distinguishes linters in a list.
*
* @return string Unique linter ID.
* @task state
*/
final public function getLinterID() {
return $this->id;
}
/* -( Executing Linters )-------------------------------------------------- */
/**
* Hook called before a list of paths are linted.
*
* Parallelizable linters can start multiple requests in parallel here,
* to improve performance. They can implement @{method:didLintPaths} to
* collect results.
*
* Linters which are not parallelizable should normally ignore this callback
* and implement @{method:lintPath} instead.
*
* @param list<string> $paths A list of paths to be linted
* @return void
* @task exec
*/
public function willLintPaths(array $paths) {
return;
}
/**
* Hook called for each path to be linted.
*
* Linters which are not parallelizable can do work here.
*
* Linters which are parallelizable may want to ignore this callback and
* implement @{method:willLintPaths} and @{method:didLintPaths} instead.
*
* @param string $path Path to lint.
* @return void
* @task exec
*/
public function lintPath($path) {
return;
}
/**
* Hook called after a list of paths are linted.
*
* Parallelizable linters can collect results here.
*
* Linters which are not paralleizable should normally ignore this callback
* and implement @{method:lintPath} instead.
*
* @param list<string> $paths A list of paths which were linted.
* @return void
* @task exec
*/
public function didLintPaths(array $paths) {
return;
}
public function getLinterPriority() {
return 1.0;
}
public function setCustomSeverityMap(array $map) {
$this->customSeverityMap = $map;
return $this;
}
public function addCustomSeverityMap(array $map) {
$this->customSeverityMap = $this->customSeverityMap + $map;
return $this;
}
public function setCustomSeverityRules(array $rules) {
$this->customSeverityRules = $rules;
return $this;
}
final public function getProjectRoot() {
$engine = $this->getEngine();
if (!$engine) {
throw new Exception(
pht(
'You must call %s before you can call %s.',
'setEngine()',
__FUNCTION__.'()'));
}
$working_copy = $engine->getWorkingCopy();
if (!$working_copy) {
return null;
}
return $working_copy->getProjectRoot();
}
final public function getOtherLocation($offset, $path = null) {
if ($path === null) {
$path = $this->getActivePath();
}
list($line, $char) = $this->getEngine()->getLineAndCharFromOffset(
$path,
$offset);
return array(
'path' => $path,
'line' => $line + 1,
'char' => $char,
);
}
final public function stopAllLinters() {
$this->stopAllLinters = true;
return $this;
}
final public function didStopAllLinters() {
return $this->stopAllLinters;
}
final public function addPath($path) {
$this->paths[$path] = $path;
$this->filteredPaths = null;
return $this;
}
final public function setPaths(array $paths) {
$this->paths = $paths;
$this->filteredPaths = null;
return $this;
}
/**
* Filter out paths which this linter doesn't act on (for example, because
* they are binaries and the linter doesn't apply to binaries).
*
* @param list<string> $paths
* @return list<string>
*/
private function filterPaths(array $paths) {
$engine = $this->getEngine();
$keep = array();
foreach ($paths as $path) {
if (!$this->shouldLintDeletedFiles() && !$engine->pathExists($path)) {
continue;
}
if (!$this->shouldLintDirectories() && $engine->isDirectory($path)) {
continue;
}
if (!$this->shouldLintBinaryFiles() && $engine->isBinaryFile($path)) {
continue;
}
if (!$this->shouldLintSymbolicLinks() && $engine->isSymbolicLink($path)) {
continue;
}
$keep[] = $path;
}
return $keep;
}
final public function getPaths() {
if ($this->filteredPaths === null) {
$this->filteredPaths = $this->filterPaths(array_values($this->paths));
}
return $this->filteredPaths;
}
final public function addData($path, $data) {
$this->data[$path] = $data;
return $this;
}
final protected function getData($path) {
if (!array_key_exists($path, $this->data)) {
$this->data[$path] = $this->getEngine()->loadData($path);
}
return $this->data[$path];
}
public function getCacheVersion() {
return 0;
}
final public function getLintMessageFullCode($short_code) {
return $this->getLinterName().$short_code;
}
final public function getLintMessageSeverity($code) {
$map = $this->customSeverityMap;
if (isset($map[$code])) {
return $map[$code];
}
foreach ($this->customSeverityRules as $rule => $severity) {
if (preg_match($rule, $code)) {
return $severity;
}
}
$map = $this->getLintSeverityMap();
if (isset($map[$code])) {
return $map[$code];
}
return $this->getDefaultMessageSeverity($code);
}
protected function getDefaultMessageSeverity($code) {
return ArcanistLintSeverity::SEVERITY_ERROR;
}
final public function isMessageEnabled($code) {
return ($this->getLintMessageSeverity($code) !==
ArcanistLintSeverity::SEVERITY_DISABLED);
}
final public function getLintMessageName($code) {
$map = $this->getLintNameMap();
if (isset($map[$code])) {
return $map[$code];
}
return pht('Unknown lint message!');
}
final protected function addLintMessage(ArcanistLintMessage $message) {
$root = $this->getProjectRoot();
$path = Filesystem::resolvePath($message->getPath(), $root);
$message->setPath(Filesystem::readablePath($path, $root));
$this->messages[] = $message;
return $message;
}
final public function getLintMessages() {
return $this->messages;
}
final public function raiseLintAtLine(
$line,
$char,
$code,
$description,
$original = null,
$replacement = null) {
$message = id(new ArcanistLintMessage())
->setPath($this->getActivePath())
->setLine($line)
->setChar($char)
->setCode($this->getLintMessageFullCode($code))
->setSeverity($this->getLintMessageSeverity($code))
->setName($this->getLintMessageName($code))
->setDescription($description)
->setOriginalText($original)
->setReplacementText($replacement);
return $this->addLintMessage($message);
}
final public function raiseLintAtPath($code, $desc) {
return $this->raiseLintAtLine(null, null, $code, $desc, null, null);
}
final public function raiseLintAtOffset(
$offset,
$code,
$description,
$original = null,
$replacement = null) {
$path = $this->getActivePath();
$engine = $this->getEngine();
if ($offset === null) {
$line = null;
$char = null;
} else {
list($line, $char) = $engine->getLineAndCharFromOffset($path, $offset);
}
return $this->raiseLintAtLine(
$line + 1,
$char + 1,
$code,
$description,
$original,
$replacement);
}
public function canRun() {
return true;
}
abstract public function getLinterName();
public function getVersion() {
return null;
}
final protected function isCodeEnabled($code) {
$severity = $this->getLintMessageSeverity($code);
return $this->getEngine()->isSeverityEnabled($severity);
}
public function getLintSeverityMap() {
return array();
}
public function getLintNameMap() {
return array();
}
public function getCacheGranularity() {
return self::GRANULARITY_FILE;
}
/**
* If this linter is selectable via `.arclint` configuration files, return
* a short, human-readable name to identify it. For example, `"jshint"` or
* `"pep8"`.
*
* If you do not implement this method, the linter will not be selectable
* through `.arclint` files.
*/
public function getLinterConfigurationName() {
return null;
}
public function getLinterConfigurationOptions() {
if (!$this->canCustomizeLintSeverities()) {
return array();
}
return array(
'severity' => array(
'type' => 'optional map<string|int, string>',
'help' => pht(
'Provide a map from lint codes to adjusted severity levels: error, '.
'warning, advice, autofix or disabled.'),
),
'severity.rules' => array(
'type' => 'optional map<string, string>',
'help' => pht(
'Provide a map of regular expressions to severity levels. All '.
'matching codes have their severity adjusted.'),
),
'standard' => array(
'type' => 'optional string | list<string>',
'help' => pht('The coding standard(s) to apply.'),
),
);
}
public function setLinterConfigurationValue($key, $value) {
$sev_map = array(
'error' => ArcanistLintSeverity::SEVERITY_ERROR,
'warning' => ArcanistLintSeverity::SEVERITY_WARNING,
'autofix' => ArcanistLintSeverity::SEVERITY_AUTOFIX,
'advice' => ArcanistLintSeverity::SEVERITY_ADVICE,
'disabled' => ArcanistLintSeverity::SEVERITY_DISABLED,
);
switch ($key) {
case 'severity':
if (!$this->canCustomizeLintSeverities()) {
break;
}
$custom = array();
foreach ($value as $code => $severity) {
if (empty($sev_map[$severity])) {
$valid = implode(', ', array_keys($sev_map));
throw new Exception(
pht(
'Unknown lint severity "%s". Valid severities are: %s.',
$severity,
$valid));
}
$code = $this->getLintCodeFromLinterConfigurationKey($code);
$custom[$code] = $severity;
}
$this->setCustomSeverityMap($custom);
return;
case 'severity.rules':
if (!$this->canCustomizeLintSeverities()) {
break;
}
foreach ($value as $rule => $severity) {
if (@preg_match($rule, '') === false) {
throw new Exception(
pht(
'Severity rule "%s" is not a valid regular expression.',
$rule));
}
if (empty($sev_map[$severity])) {
$valid = implode(', ', array_keys($sev_map));
throw new Exception(
pht(
'Unknown lint severity "%s". Valid severities are: %s.',
$severity,
$valid));
}
}
$this->setCustomSeverityRules($value);
return;
case 'standard':
$standards = (array)$value;
foreach ($standards as $standard_name) {
$standard = ArcanistLinterStandard::getStandard(
$standard_name,
$this);
foreach ($standard->getLinterConfiguration() as $k => $v) {
$this->setLinterConfigurationValue($k, $v);
}
$this->addCustomSeverityMap($standard->getLinterSeverityMap());
}
return;
}
throw new Exception(pht('Incomplete implementation: %s!', $key));
}
protected function canCustomizeLintSeverities() {
return true;
}
protected function shouldLintBinaryFiles() {
return false;
}
protected function shouldLintDeletedFiles() {
return false;
}
protected function shouldLintDirectories() {
return false;
}
protected function shouldLintSymbolicLinks() {
return false;
}
/**
* Map a configuration lint code to an `arc` lint code. Primarily, this is
* intended for validation, but can also be used to normalize case or
* otherwise be more permissive in accepted inputs.
*
* If the code is not recognized, you should throw an exception.
*
* @param string $code Code specified in configuration.
* @return string Normalized code to use in severity map.
*/
protected function getLintCodeFromLinterConfigurationKey($code) {
return $code;
}
}
diff --git a/src/lint/linter/ArcanistXHPASTLinter.php b/src/lint/linter/ArcanistXHPASTLinter.php
index d5194319..e330f856 100644
--- a/src/lint/linter/ArcanistXHPASTLinter.php
+++ b/src/lint/linter/ArcanistXHPASTLinter.php
@@ -1,161 +1,161 @@
<?php
/**
* Uses XHPAST to apply lint rules to PHP.
*/
final class ArcanistXHPASTLinter extends ArcanistBaseXHPASTLinter {
private $rules = array();
private $lintNameMap;
private $lintSeverityMap;
public function __construct() {
$this->setRules(ArcanistXHPASTLinterRule::loadAllRules());
}
public function __clone() {
$rules = $this->rules;
$this->rules = array();
foreach ($rules as $rule) {
$this->rules[] = clone $rule;
}
}
/**
* Set the XHPAST linter rules which are enforced by this linter.
*
* This is primarily useful for unit tests in which it is desirable to test
* linter rules in isolation. By default, all linter rules will be enabled.
*
* @param list<ArcanistXHPASTLinterRule> $rules
- * @return this
+ * @return $this
*/
public function setRules(array $rules) {
assert_instances_of($rules, 'ArcanistXHPASTLinterRule');
$this->rules = $rules;
return $this;
}
public function getInfoName() {
return pht('XHPAST Lint');
}
public function getInfoDescription() {
return pht('Use XHPAST to enforce coding conventions on PHP source files.');
}
public function getAdditionalInformation() {
$table = id(new PhutilConsoleTable())
->setBorders(true)
->addColumn('id', array('title' => pht('ID')))
->addColumn('class', array('title' => pht('Class')))
->addColumn('name', array('title' => pht('Name')));
$rules = $this->rules;
ksort($rules);
foreach ($rules as $id => $rule) {
$table->addRow(array(
'id' => $id,
'class' => get_class($rule),
'name' => $rule->getLintName(),
));
}
return array(
pht('Linter Rules') => $table->drawConsoleString(),
);
}
public function getLinterName() {
return 'XHP';
}
public function getLinterConfigurationName() {
return 'xhpast';
}
public function getLintNameMap() {
if ($this->lintNameMap === null) {
$this->lintNameMap = mpull(
$this->rules,
'getLintName',
'getLintID');
}
return $this->lintNameMap;
}
public function getLintSeverityMap() {
if ($this->lintSeverityMap === null) {
$this->lintSeverityMap = mpull(
$this->rules,
'getLintSeverity',
'getLintID');
}
return $this->lintSeverityMap;
}
public function getLinterConfigurationOptions() {
return parent::getLinterConfigurationOptions() + array_mergev(
mpull($this->rules, 'getLinterConfigurationOptions'));
}
public function setLinterConfigurationValue($key, $value) {
$matched = false;
foreach ($this->rules as $rule) {
foreach ($rule->getLinterConfigurationOptions() as $k => $spec) {
if ($k == $key) {
$matched = true;
$rule->setLinterConfigurationValue($key, $value);
}
}
}
if ($matched) {
return;
}
return parent::setLinterConfigurationValue($key, $value);
}
public function getVersion() {
// TODO: Improve this.
return count($this->rules);
}
protected function resolveFuture($path, Future $future) {
$tree = $this->getXHPASTTreeForPath($path);
if (!$tree) {
$ex = $this->getXHPASTExceptionForPath($path);
if ($ex instanceof XHPASTSyntaxErrorException) {
$this->raiseLintAtLine(
$ex->getErrorLine(),
1,
ArcanistSyntaxErrorXHPASTLinterRule::ID,
pht(
'This file contains a syntax error: %s',
$ex->getMessage()));
} else if ($ex instanceof Exception) {
$this->raiseLintAtPath(
ArcanistUnableToParseXHPASTLinterRule::ID,
$ex->getMessage());
}
return;
}
$root = $tree->getRootNode();
foreach ($this->rules as $rule) {
if ($this->isCodeEnabled($rule->getLintID())) {
$rule->setLinter($this);
$rule->process($root);
}
}
}
}
diff --git a/src/lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php b/src/lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php
index 24ccb577..fba5b720 100644
--- a/src/lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php
+++ b/src/lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php
@@ -1,154 +1,154 @@
<?php
/**
* You can extend this class and set `xhpast.naminghook` in your `.arclint` to
* have an opportunity to override lint results for symbol names.
*
* @task override Overriding Symbol Name Lint Messages
* @task util Name Utilities
* @task internal Internals
*/
abstract class ArcanistXHPASTLintNamingHook extends Phobject {
/* -( Internals )---------------------------------------------------------- */
/**
* The constructor is final because @{class:ArcanistXHPASTLinter} is
* responsible for hook instantiation.
*
- * @return this
+ * @return $this
* @task internals
*/
final public function __construct() {
// <empty>
}
/* -( Overriding Symbol Name Lint Messages )------------------------------- */
/**
* Callback invoked for each symbol, which can override the default
* determination of name validity or accept it by returning $default. The
* symbol types are: xhp-class, class, interface, function, method, parameter,
* constant, and member.
*
* For example, if you want to ban all symbols with "quack" in them and
* otherwise accept all the defaults, except allow any naming convention for
* methods with "duck" in them, you might implement the method like this:
*
* if (preg_match('/quack/i', $name)) {
* return 'Symbol names containing "quack" are forbidden.';
* }
* if ($type == 'method' && preg_match('/duck/i', $name)) {
* return null; // Always accept.
* }
* return $default;
*
* @param string $type The symbol type.
* @param string $name The symbol name.
* @param string|null $default The default result from the main rule
* engine.
* @return string|null Null to accept the name, or a message to reject it
* with. You should return the default value if you
* don't want to specifically provide an override.
* @task override
*/
abstract public function lintSymbolName($type, $name, $default);
/* -( Name Utilities )----------------------------------------------------- */
/**
* Returns true if a symbol name is UpperCamelCase.
*
* @param string $symbol Symbol name.
* @return bool True if the symbol is UpperCamelCase.
* @task util
*/
public static function isUpperCamelCase($symbol) {
return preg_match('/^[A-Z][A-Za-z0-9]*$/', $symbol);
}
/**
* Returns true if a symbol name is lowerCamelCase.
*
* @param string $symbol Symbol name.
* @return bool True if the symbol is lowerCamelCase.
* @task util
*/
public static function isLowerCamelCase($symbol) {
return preg_match('/^[a-z][A-Za-z0-9]*$/', $symbol);
}
/**
* Returns true if a symbol name is UPPERCASE_WITH_UNDERSCORES.
*
* @param string $symbol Symbol name.
* @return bool True if the symbol is UPPERCASE_WITH_UNDERSCORES.
* @task util
*/
public static function isUppercaseWithUnderscores($symbol) {
return preg_match('/^[A-Z0-9_]+$/', $symbol);
}
/**
* Returns true if a symbol name is lowercase_with_underscores.
*
* @param string $symbol Symbol name.
* @return bool True if the symbol is lowercase_with_underscores.
* @task util
*/
public static function isLowercaseWithUnderscores($symbol) {
return preg_match('/^[a-z0-9_]+$/', $symbol);
}
/**
* Strip non-name components from PHP function symbols. Notably, this discards
* the "__" magic-method signifier, to make a symbol appropriate for testing
* with methods like @{method:isLowerCamelCase}.
*
* @param string $symbol Symbol name.
* @return string Stripped symbol.
* @task util
*/
public static function stripPHPFunction($symbol) {
switch ($symbol) {
case '__assign_concat':
case '__call':
case '__callStatic':
case '__clone':
case '__concat':
case '__construct':
case '__debugInfo':
case '__destruct':
case '__get':
case '__invoke':
case '__isset':
case '__set':
case '__set_state':
case '__sleep':
case '__toString':
case '__unset':
case '__wakeup':
return preg_replace('/^__/', '', $symbol);
default:
return $symbol;
}
}
/**
* Strip non-name components from PHP variable symbols. Notably, this discards
* the "$", to make a symbol appropriate for testing with methods like
* @{method:isLowercaseWithUnderscores}.
*
* @param string $symbol Symbol name.
* @return string Stripped symbol.
* @task util
*/
public static function stripPHPVariable($symbol) {
return preg_replace('/^\$/', '', $symbol);
}
}
diff --git a/src/moduleutils/PhutilLibraryMapBuilder.php b/src/moduleutils/PhutilLibraryMapBuilder.php
index e0f7afab..38a1bd21 100644
--- a/src/moduleutils/PhutilLibraryMapBuilder.php
+++ b/src/moduleutils/PhutilLibraryMapBuilder.php
@@ -1,489 +1,489 @@
<?php
/**
* Build maps of libphutil libraries. libphutil uses the library map to locate
* and load classes and functions in the library.
*
* @task map Mapping libphutil Libraries
* @task path Path Management
* @task symbol Symbol Analysis and Caching
* @task source Source Management
*/
final class PhutilLibraryMapBuilder extends Phobject {
private $root;
private $subprocessLimit = 8;
private $fileSymbolMap;
private $librarySymbolMap;
const LIBRARY_MAP_VERSION_KEY = '__library_version__';
const LIBRARY_MAP_VERSION = 2;
const SYMBOL_CACHE_VERSION_KEY = '__symbol_cache_version__';
const SYMBOL_CACHE_VERSION = 11;
/* -( Mapping libphutil Libraries )---------------------------------------- */
/**
* Create a new map builder for a library.
*
* @param string $root Path to the library root.
*
* @task map
*/
public function __construct($root) {
$this->root = $root;
}
/**
* Control subprocess parallelism limit. Use `--limit` to set this.
*
* @param int $limit Maximum number of subprocesses to run in parallel.
- * @return this
+ * @return $this
*
* @task map
*/
public function setSubprocessLimit($limit) {
$this->subprocessLimit = $limit;
return $this;
}
/**
* Get the map of symbols in this library, analyzing the library to build it
* if necessary.
*
* @return map<string, wild> Information about symbols in this library.
*
* @task map
*/
public function buildMap() {
if ($this->librarySymbolMap === null) {
$this->analyzeLibrary();
}
return $this->librarySymbolMap;
}
/**
* Get the map of files in this library, analyzing the library to build it
* if necessary.
*
* Returns a map of file paths to information about symbols used and defined
* in the file.
*
* @return map<string, wild> Information about files in this library.
*
* @task map
*/
public function buildFileSymbolMap() {
if ($this->fileSymbolMap === null) {
$this->analyzeLibrary();
}
return $this->fileSymbolMap;
}
/**
* Build and update the library map.
*
* @return void
*
* @task map
*/
public function buildAndWriteMap() {
$library_map = $this->buildMap();
$this->writeLibraryMap($library_map);
}
/* -( Path Management )---------------------------------------------------- */
/**
* Get the path to some file in the library.
*
* @param string $path (optional) A library-relative path. If omitted,
* returns the library root path.
* @return string An absolute path.
*
* @task path
*/
private function getPath($path = '') {
return $this->root.'/'.$path;
}
/**
* Get the path to the symbol cache file.
*
* @return string Absolute path to symbol cache.
*
* @task path
*/
private function getPathForSymbolCache() {
return $this->getPath('.phutil_module_cache');
}
/**
* Get the path to the map file.
*
* @return string Absolute path to the library map.
*
* @task path
*/
private function getPathForLibraryMap() {
return $this->getPath('__phutil_library_map__.php');
}
/**
* Get the path to the library init file.
*
* @return string Absolute path to the library init file
*
* @task path
*/
private function getPathForLibraryInit() {
return $this->getPath('__phutil_library_init__.php');
}
/* -( Symbol Analysis and Caching )---------------------------------------- */
/**
* Load the library symbol cache, if it exists and is readable and valid.
*
* @return dict Map of content hashes to cache of output from
* `extract-symbols.php`.
*
* @task symbol
*/
private function loadSymbolCache() {
$cache_file = $this->getPathForSymbolCache();
try {
$cache = Filesystem::readFile($cache_file);
} catch (Exception $ex) {
$cache = null;
}
$symbol_cache = array();
if ($cache) {
try {
$symbol_cache = phutil_json_decode($cache);
} catch (PhutilJSONParserException $ex) {
$symbol_cache = array();
}
}
$version = idx($symbol_cache, self::SYMBOL_CACHE_VERSION_KEY);
if ($version != self::SYMBOL_CACHE_VERSION) {
// Throw away caches from a different version of the library.
$symbol_cache = array();
}
unset($symbol_cache[self::SYMBOL_CACHE_VERSION_KEY]);
return $symbol_cache;
}
/**
* Write a symbol map to disk cache.
*
* @param dict $symbol_map Symbol map of relative paths to symbols.
* @param dict $source_map Source map (like @{method:loadSourceFileMap}).
* @return void
*
* @task symbol
*/
private function writeSymbolCache(array $symbol_map, array $source_map) {
$cache_file = $this->getPathForSymbolCache();
$cache = array(
self::SYMBOL_CACHE_VERSION_KEY => self::SYMBOL_CACHE_VERSION,
);
foreach ($symbol_map as $file => $symbols) {
$cache[$source_map[$file]] = $symbols;
}
$json = json_encode($cache);
Filesystem::writeFile($cache_file, $json);
}
/**
* Drop the symbol cache, forcing a clean rebuild.
*
* @return void
*
* @task symbol
*/
public function dropSymbolCache() {
Filesystem::remove($this->getPathForSymbolCache());
}
/**
* Build a future which returns a `extract-symbols.php` analysis of a source
* file.
*
* @param string $file Relative path to the source file to analyze.
* @return Future Analysis future.
*
* @task symbol
*/
private function buildSymbolAnalysisFuture($file) {
$absolute_file = $this->getPath($file);
return self::newExtractSymbolsFuture(
array(),
array($absolute_file));
}
private static function newExtractSymbolsFuture(array $flags, array $paths) {
$bin = dirname(__FILE__).'/../../support/lib/extract-symbols.php';
return new ExecFuture(
'php -f %R -- --ugly %Ls -- %Ls',
$bin,
$flags,
$paths);
}
public static function newBuiltinMap() {
$future = self::newExtractSymbolsFuture(
array('--builtins'),
array());
list($json) = $future->resolvex();
return phutil_json_decode($json);
}
/* -( Source Management )-------------------------------------------------- */
/**
* Build a map of all source files in a library to hashes of their content.
* Returns an array like this:
*
* array(
* 'src/parser/ExampleParser.php' => '60b725f10c9c85c70d97880dfe8191b3',
* // ...
* );
*
* @return dict Map of library-relative paths to content hashes.
* @task source
*/
private function loadSourceFileMap() {
$root = $this->getPath();
$init = $this->getPathForLibraryInit();
if (!Filesystem::pathExists($init)) {
throw new Exception(
pht(
"Provided path '%s' is not a %s library.",
$root,
'phutil'));
}
$files = id(new FileFinder($root))
->withType('f')
->withSuffix('php')
->excludePath('*/.*')
->setGenerateChecksums(true)
->find();
$extensions_dir = 'extensions/';
$extensions_len = strlen($extensions_dir);
$map = array();
foreach ($files as $file => $hash) {
$file = Filesystem::readablePath($file, $root);
$file = ltrim($file, '/');
if (dirname($file) == '.') {
// We don't permit normal source files at the root level, so just ignore
// them; they're special library files.
continue;
}
// Ignore files in the extensions/ directory.
if (!strncmp($file, $extensions_dir, $extensions_len)) {
continue;
}
// We include also filename in the hash to handle cases when the file is
// moved without modifying its content.
$map[$file] = md5($hash.$file);
}
return $map;
}
/**
* Convert the symbol analysis of all the source files in the library into
* a library map.
*
* @param dict $symbol_map Symbol analysis of all source files.
* @return dict Library map.
* @task source
*/
private function buildLibraryMap(array $symbol_map) {
$library_map = array(
'class' => array(),
'function' => array(),
'xmap' => array(),
);
$type_translation = array(
'interface' => 'class',
'trait' => 'class',
);
// Detect duplicate symbols within the library.
foreach ($symbol_map as $file => $info) {
foreach ($info['have'] as $type => $symbols) {
foreach ($symbols as $symbol => $declaration) {
$lib_type = idx($type_translation, $type, $type);
if (!empty($library_map[$lib_type][$symbol])) {
$prior = $library_map[$lib_type][$symbol];
throw new Exception(
pht(
"Definition of %s '%s' in file '%s' duplicates prior ".
"definition in file '%s'. You can not declare the ".
"same symbol twice.",
$type,
$symbol,
$file,
$prior));
}
$library_map[$lib_type][$symbol] = $file;
}
}
$library_map['xmap'] += $info['xmap'];
}
// Simplify the common case (one parent) to make the file a little easier
// to deal with.
foreach ($library_map['xmap'] as $class => $extends) {
if (count($extends) == 1) {
$library_map['xmap'][$class] = reset($extends);
}
}
// Sort the map so it is relatively stable across changes.
foreach ($library_map as $key => $symbols) {
ksort($symbols);
$library_map[$key] = $symbols;
}
ksort($library_map);
return $library_map;
}
/**
* Write a finalized library map.
*
* @param dict $library_map Library map structure to write.
* @return void
*
* @task source
*/
private function writeLibraryMap(array $library_map) {
$map_file = $this->getPathForLibraryMap();
$version = self::LIBRARY_MAP_VERSION;
$library_map = array(
self::LIBRARY_MAP_VERSION_KEY => $version,
) + $library_map;
$library_map = phutil_var_export($library_map);
$at = '@';
$source_file = <<<EOPHP
<?php
/**
* This file is automatically generated. Use 'arc liberate' to rebuild it.
*
* {$at}generated
* {$at}phutil-library-version {$version}
*/
phutil_register_library_map({$library_map});
EOPHP;
Filesystem::writeFile($map_file, $source_file);
}
/**
* Analyze the library, generating the file and symbol maps.
*
* @return void
*/
private function analyzeLibrary() {
// Identify all the ".php" source files in the library.
$source_map = $this->loadSourceFileMap();
// Load the symbol cache with existing parsed symbols. This allows us
// to remap libraries quickly by analyzing only changed files.
$symbol_cache = $this->loadSymbolCache();
// If the XHPAST binary is not up-to-date, build it now. Otherwise,
// `extract-symbols.php` will attempt to build the binary and will fail
// miserably because it will be trying to build the same file multiple
// times in parallel.
if (!PhutilXHPASTBinary::isAvailable()) {
PhutilXHPASTBinary::build();
}
// Build out the symbol analysis for all the files in the library. For
// each file, check if it's in cache. If we miss in the cache, do a fresh
// analysis.
$symbol_map = array();
$futures = array();
foreach ($source_map as $file => $hash) {
if (!empty($symbol_cache[$hash])) {
$symbol_map[$file] = $symbol_cache[$hash];
continue;
}
$futures[$file] = $this->buildSymbolAnalysisFuture($file);
}
// Run the analyzer on any files which need analysis.
if ($futures) {
$limit = $this->subprocessLimit;
$progress = new PhutilConsoleProgressBar();
$progress->setTotal(count($futures));
$futures = id(new FutureIterator($futures))
->limit($limit);
foreach ($futures as $file => $future) {
$result = $future->resolveJSON();
if (empty($result['error'])) {
$symbol_map[$file] = $result;
} else {
$progress->done(false);
throw new XHPASTSyntaxErrorException(
$result['line'],
$file.': '.$result['error']);
}
$progress->update(1);
}
$progress->done();
}
$this->fileSymbolMap = $symbol_map;
if ($futures) {
// We're done building/updating the cache, so write it out immediately.
// Note that we've only retained entries for files we found, so this
// implicitly cleans out old cache entries.
$this->writeSymbolCache($symbol_map, $source_map);
}
// Our map is up to date, so either show it on stdout or write it to disk.
$this->librarySymbolMap = $this->buildLibraryMap($symbol_map);
}
}
diff --git a/src/parser/PhutilSimpleOptions.php b/src/parser/PhutilSimpleOptions.php
index c763051b..bea5b33d 100644
--- a/src/parser/PhutilSimpleOptions.php
+++ b/src/parser/PhutilSimpleOptions.php
@@ -1,195 +1,195 @@
<?php
/**
* Utilities for parsing simple option lists used in Remarkup, like codeblocks:
*
* lang=text
* lang=php, name=example.php, lines=30, counterexample
*
* @task parse Parsing Simple Options
* @task unparse Unparsing Simple Options
* @task config Parser Configuration
* @task internal Internals
*/
final class PhutilSimpleOptions extends Phobject {
private $caseSensitive;
/* -( Parsing Simple Options )--------------------------------------------- */
/**
* Convert a simple option list into a dict. For example:
*
* legs=4, eyes=2
*
* ...becomes:
*
* array(
* 'legs' => '4',
* 'eyes' => '2',
* );
*
* @param string $input Input option list.
* @return dict Parsed dictionary.
* @task parse
*/
public function parse($input) {
$result = array();
$lexer = new PhutilSimpleOptionsLexer();
$tokens = $lexer->getNiceTokens($input);
$state = 'key';
$pairs = array();
foreach ($tokens as $token) {
list($type, $value) = $token;
switch ($state) {
case 'key':
if ($type != 'word') {
return array();
}
if (!strlen($value)) {
return array();
}
$key = $this->normalizeKey($value);
$state = '=';
break;
case '=':
if ($type == '=') {
$state = 'value';
break;
}
if ($type == ',') {
$pairs[] = array($key, true);
$state = 'key';
break;
}
return array();
case 'value':
if ($type == ',') {
$pairs[] = array($key, null);
$state = 'key';
break;
}
if ($type != 'word') {
return array();
}
$pairs[] = array($key, $value);
$state = ',';
break;
case ',':
if ($type == 'word') {
$pair = array_pop($pairs);
$pair[1] .= $value;
$pairs[] = $pair;
break;
}
if ($type != ',') {
return array();
}
$state = 'key';
break;
}
}
if ($state == '=') {
$pairs[] = array($key, true);
}
if ($state == 'value') {
$pairs[] = array($key, null);
}
$result = array();
foreach ($pairs as $pair) {
list($key, $value) = $pair;
if ($value === null) {
unset($result[$key]);
} else {
$result[$key] = $value;
}
}
return $result;
}
/* -( Unparsing Simple Options )------------------------------------------- */
/**
* Convert a dictionary into a simple option list. For example:
*
* array(
* 'legs' => '4',
* 'eyes' => '2',
* );
*
* ...becomes:
*
* legs=4, eyes=2
*
* @param dict $options Input dictionary.
* @param string $escape (optional) Additional characters to escape.
* @return string Unparsed option list.
*/
public function unparse(array $options, $escape = '') {
$result = array();
foreach ($options as $name => $value) {
$name = $this->normalizeKey($name);
if (!strlen($value)) {
continue;
}
if ($value === true) {
$result[] = $this->quoteString($name, $escape);
} else {
$qn = $this->quoteString($name, $escape);
$qv = $this->quoteString($value, $escape);
$result[] = $qn.'='.$qv;
}
}
return implode(', ', $result);
}
/* -( Parser Configuration )----------------------------------------------- */
/**
* Configure case sensitivity of the parser. By default, the parser is
* case insensitive, so "legs=4" has the same meaning as "LEGS=4". If you
* set it to be case sensitive, the keys have different meanings.
*
* @param bool $case_sensitive True to make the parser case sensitive, false
* to make it case-insensitive.
- * @return this
+ * @return $this
* @task config
*/
public function setCaseSensitive($case_sensitive) {
$this->caseSensitive = $case_sensitive;
return $this;
}
/* -( Internals )---------------------------------------------------------- */
private function normalizeKey($key) {
if (!strlen($key)) {
throw new Exception(pht('Empty key is invalid!'));
}
if (!$this->caseSensitive) {
$key = strtolower($key);
}
return $key;
}
private function quoteString($string, $escape) {
if (preg_match('/[^a-zA-Z0-9]/', $string)) {
$string = '"'.addcslashes($string, '\\\'"'.$escape).'"';
}
return $string;
}
}
diff --git a/src/parser/argument/PhutilArgumentParser.php b/src/parser/argument/PhutilArgumentParser.php
index fccb8e9d..1d6f3a23 100644
--- a/src/parser/argument/PhutilArgumentParser.php
+++ b/src/parser/argument/PhutilArgumentParser.php
@@ -1,1045 +1,1045 @@
<?php
/**
* Parser for command-line arguments for scripts. Like similar parsers, this
* class allows you to specify, validate, and render help for command-line
* arguments. For example:
*
* name=create_dog.php
* $args = new PhutilArgumentParser($argv);
* $args->setTagline('make an new dog')
* $args->setSynopsis(<<<EOHELP
* **dog** [--big] [--name __name__]
* Create a new dog. How does it work? Who knows.
* EOHELP
* );
* $args->parse(
* array(
* array(
* 'name' => 'name',
* 'param' => 'dogname',
* 'default' => 'Rover',
* 'help' => 'Set the dog\'s name. By default, the dog will be '.
* 'named "Rover".',
* ),
* array(
* 'name' => 'big',
* 'short' => 'b',
* 'help' => 'If set, create a large dog.',
* ),
* ));
*
* $dog_name = $args->getArg('name');
* $dog_size = $args->getArg('big') ? 'big' : 'small';
*
* // ... etc ...
*
* (For detailed documentation on supported keys in argument specifications,
* see @{class:PhutilArgumentSpecification}.)
*
* This will handle argument parsing, and generate appropriate usage help if
* the user provides an unsupported flag. @{class:PhutilArgumentParser} also
* supports some builtin "standard" arguments:
*
* $args->parseStandardArguments();
*
* See @{method:parseStandardArguments} for details. Notably, this includes
* a "--help" flag, and an "--xprofile" flag for profiling command-line scripts.
*
* Normally, when the parser encounters an unknown flag, it will exit with
* an error. However, you can use @{method:parsePartial} to consume only a
* set of flags:
*
* $args->parsePartial($spec_list);
*
* This allows you to parse some flags before making decisions about other
* parsing, or share some flags across scripts. The builtin standard arguments
* are implemented in this way.
*
* There is also builtin support for "workflows", which allow you to build a
* script that operates in several modes (e.g., by accepting commands like
* `install`, `upgrade`, etc), like `arc` does. For detailed documentation on
* workflows, see @{class:PhutilArgumentWorkflow}.
*
* @task parse Parsing Arguments
* @task read Reading Arguments
* @task help Command Help
* @task internal Internals
*/
final class PhutilArgumentParser extends Phobject {
private $bin;
private $argv;
private $specs = array();
private $results = array();
private $parsed;
private $tagline;
private $synopsis;
private $workflows;
private $helpWorkflows;
private $showHelp;
private $requireArgumentTerminator = false;
private $sawTerminator = false;
const PARSE_ERROR_CODE = 77;
private static $traceModeEnabled = false;
/* -( Parsing Arguments )-------------------------------------------------- */
/**
* Build a new parser. Generally, you start a script with:
*
* $args = new PhutilArgumentParser($argv);
*
* @param list $argv Argument vector to parse, generally the $argv global.
* @task parse
*/
public function __construct(array $argv) {
$this->bin = $argv[0];
$this->argv = array_slice($argv, 1);
}
/**
* Parse and consume a list of arguments, removing them from the argument
* vector but leaving unparsed arguments for later consumption. You can
* retrieve unconsumed arguments directly with
* @{method:getUnconsumedArgumentVector}. Doing a partial parse can make it
* easier to share common flags across scripts or workflows.
*
* @param list $specs List of argument specs, see
* @{class:PhutilArgumentSpecification}.
* @param bool $initial_only (optional) Require flags appear before any
* non-flag arguments.
- * @return this
+ * @return $this
* @task parse
*/
public function parsePartial(array $specs, $initial_only = false) {
return $this->parseInternal($specs, false, $initial_only);
}
/**
- * @return this
+ * @return $this
*/
private function parseInternal(
array $specs,
$correct_spelling,
$initial_only) {
$specs = PhutilArgumentSpecification::newSpecsFromList($specs);
$this->mergeSpecs($specs);
// Wildcard arguments have a name like "argv", but we don't want to
// parse a corresponding flag like "--argv". Filter them out before
// building a list of available flags.
$non_wildcard = array();
foreach ($specs as $spec_key => $spec) {
if ($spec->getWildcard()) {
continue;
}
$non_wildcard[$spec_key] = $spec;
}
$specs_by_name = mpull($non_wildcard, null, 'getName');
$specs_by_short = mpull($non_wildcard, null, 'getShortAlias');
unset($specs_by_short[null]);
$argv = $this->argv;
$len = count($argv);
$is_initial = true;
for ($ii = 0; $ii < $len; $ii++) {
$arg = $argv[$ii];
$map = null;
$options = null;
if (!is_string($arg)) {
// Non-string argument; pass it through as-is.
} else if ($arg == '--') {
// This indicates "end of flags".
$this->sawTerminator = true;
break;
} else if ($arg == '-') {
// This is a normal argument (e.g., stdin).
continue;
} else if (!strncmp('--', $arg, 2)) {
$pre = '--';
$arg = substr($arg, 2);
$map = $specs_by_name;
$options = array_keys($specs_by_name);
} else if (!strncmp('-', $arg, 1) && strlen($arg) > 1) {
$pre = '-';
$arg = substr($arg, 1);
$map = $specs_by_short;
} else {
$is_initial = false;
}
if ($map) {
$val = null;
$parts = explode('=', $arg, 2);
if (count($parts) == 2) {
list($arg, $val) = $parts;
}
// Try to correct flag spelling for full flags, to allow users to make
// minor mistakes.
if ($correct_spelling && $options && !isset($map[$arg])) {
$corrections = PhutilArgumentSpellingCorrector::newFlagCorrector()
->correctSpelling($arg, $options);
$should_autocorrect = $this->shouldAutocorrect();
if (count($corrections) == 1 && $should_autocorrect) {
$corrected = head($corrections);
$this->logMessage(
tsprintf(
"%s\n",
pht(
'(Assuming "%s" is the British spelling of "%s".)',
$pre.$arg,
$pre.$corrected)));
$arg = $corrected;
}
}
if (isset($map[$arg])) {
if ($initial_only && !$is_initial) {
throw new PhutilArgumentUsageException(
pht(
'Argument "%s" appears after the first non-flag argument. '.
'This special argument must appear before other arguments.',
"{$pre}{$arg}"));
}
$spec = $map[$arg];
unset($argv[$ii]);
$param_name = $spec->getParamName();
if ($val !== null) {
if ($param_name === null) {
throw new PhutilArgumentUsageException(
pht(
'Argument "%s" does not take a parameter.',
"{$pre}{$arg}"));
}
} else {
if ($param_name !== null) {
if ($ii + 1 < $len) {
$val = $argv[$ii + 1];
unset($argv[$ii + 1]);
$ii++;
} else {
throw new PhutilArgumentUsageException(
pht(
'Argument "%s" requires a parameter.',
"{$pre}{$arg}"));
}
} else {
$val = true;
}
}
if (!$spec->getRepeatable()) {
if (array_key_exists($spec->getName(), $this->results)) {
throw new PhutilArgumentUsageException(
pht(
'Argument "%s" was provided twice.',
"{$pre}{$arg}"));
}
}
$conflicts = $spec->getConflicts();
foreach ($conflicts as $conflict => $reason) {
if (array_key_exists($conflict, $this->results)) {
if (!is_string($reason) || !strlen($reason)) {
$reason = '.';
} else {
$reason = ': '.$reason.'.';
}
throw new PhutilArgumentUsageException(
pht(
'Argument "%s" conflicts with argument "%s"%s',
"{$pre}{$arg}",
"--{$conflict}",
$reason));
}
}
if ($spec->getRepeatable()) {
if ($spec->getParamName() === null) {
if (empty($this->results[$spec->getName()])) {
$this->results[$spec->getName()] = 0;
}
$this->results[$spec->getName()]++;
} else {
$this->results[$spec->getName()][] = $val;
}
} else {
$this->results[$spec->getName()] = $val;
}
}
}
}
foreach ($specs as $spec) {
if ($spec->getWildcard()) {
$this->results[$spec->getName()] = $this->filterWildcardArgv($argv);
$argv = array();
break;
}
}
$this->argv = array_values($argv);
return $this;
}
/**
* Parse and consume a list of arguments, throwing an exception if there is
* anything left unconsumed. This is like @{method:parsePartial}, but raises
* a {class:PhutilArgumentUsageException} if there are leftovers.
*
* Normally, you would call @{method:parse} instead, which emits a
* user-friendly error. You can also use @{method:printUsageException} to
* render the exception in a user-friendly way.
*
* @param list $specs List of argument specs, see
* @{class:PhutilArgumentSpecification}.
- * @return this
+ * @return $this
* @task parse
*/
public function parseFull(array $specs) {
$this->parseInternal($specs, true, false);
// If we have remaining unconsumed arguments other than a single "--",
// fail.
$argv = $this->filterWildcardArgv($this->argv);
if ($argv) {
throw new PhutilArgumentUsageException(
pht(
'Unrecognized argument "%s".',
head($argv)));
}
if ($this->getRequireArgumentTerminator()) {
if (!$this->sawTerminator) {
throw new ArcanistMissingArgumentTerminatorException();
}
}
if ($this->showHelp) {
$this->printHelpAndExit();
}
return $this;
}
/**
* Parse and consume a list of arguments, raising a user-friendly error if
* anything remains. See also @{method:parseFull} and @{method:parsePartial}.
*
* @param list $specs List of argument specs, see
* @{class:PhutilArgumentSpecification}.
- * @return this
+ * @return $this
* @task parse
*/
public function parse(array $specs) {
try {
return $this->parseFull($specs);
} catch (PhutilArgumentUsageException $ex) {
$this->printUsageException($ex);
exit(self::PARSE_ERROR_CODE);
}
}
/**
* Parse and execute workflows, raising a user-friendly error if anything
* remains. See also @{method:parseWorkflowsFull}.
*
* See @{class:PhutilArgumentWorkflow} for details on using workflows.
*
* @param list $workflows List of argument specs, see
* @{class:PhutilArgumentSpecification}.
- * @return this
+ * @return $this
* @task parse
*/
public function parseWorkflows(array $workflows) {
try {
return $this->parseWorkflowsFull($workflows);
} catch (PhutilArgumentUsageException $ex) {
$this->printUsageException($ex);
exit(self::PARSE_ERROR_CODE);
}
}
/**
* Select a workflow. For commands that may operate in several modes, like
* `arc`, the modes can be split into "workflows". Each workflow specifies
* the arguments it accepts. This method takes a list of workflows, selects
* the chosen workflow, parses its arguments, and either executes it (if it
* is executable) or returns it for handling.
*
* See @{class:PhutilArgumentWorkflow} for details on using workflows.
*
* @param list $workflows List of @{class:PhutilArgumentWorkflow}s.
* @return PhutilArgumentWorkflow|no Returns the chosen workflow if it is
* not executable, or executes it and
* exits with a return code if it is.
* @task parse
*/
public function parseWorkflowsFull(array $workflows) {
assert_instances_of($workflows, 'PhutilArgumentWorkflow');
// Clear out existing workflows. We need to do this to permit the
// construction of sub-workflows.
$this->workflows = array();
foreach ($workflows as $workflow) {
$name = $workflow->getName();
if ($name === null) {
throw new PhutilArgumentSpecificationException(
pht('Workflow has no name!'));
}
if (isset($this->workflows[$name])) {
throw new PhutilArgumentSpecificationException(
pht("Two workflows with name '%s!", $name));
}
$this->workflows[$name] = $workflow;
}
$argv = $this->argv;
if (empty($argv)) {
// TODO: this is kind of hacky / magical.
if (isset($this->workflows['help'])) {
$argv = array('help');
} else {
throw new PhutilArgumentUsageException(pht('No workflow selected.'));
}
}
$flow = array_shift($argv);
if (empty($this->workflows[$flow])) {
$corrected = PhutilArgumentSpellingCorrector::newCommandCorrector()
->correctSpelling($flow, array_keys($this->workflows));
$should_autocorrect = $this->shouldAutocorrect();
if (count($corrected) == 1 && $should_autocorrect) {
$corrected = head($corrected);
$this->logMessage(
tsprintf(
"%s\n",
pht(
'(Assuming "%s" is the British spelling of "%s".)',
$flow,
$corrected)));
$flow = $corrected;
} else {
if (!$this->showHelp) {
$this->raiseUnknownWorkflow($flow, $corrected);
}
}
}
$workflow = idx($this->workflows, $flow);
if ($this->showHelp) {
// Make "cmd flow --help" behave like "cmd help flow", not "cmd help".
$help_flow = idx($this->workflows, 'help');
if ($help_flow) {
if ($help_flow !== $workflow) {
$workflow = $help_flow;
$argv = array($flow);
// Prevent parse() from dumping us back out to standard help.
$this->showHelp = false;
}
} else {
$this->printHelpAndExit();
}
}
if (!$workflow) {
$this->raiseUnknownWorkflow($flow, $corrected);
}
$this->argv = array_values($argv);
if ($workflow->shouldParsePartial()) {
$this->parsePartial($workflow->getArguments());
} else {
$this->parse($workflow->getArguments());
}
if ($workflow->isExecutable()) {
$workflow->setArgv($this);
$err = $workflow->execute($this);
exit($err);
} else {
return $workflow;
}
}
/**
* Parse "standard" arguments and apply their effects:
*
* --trace Enable service call tracing.
* --no-ansi Disable ANSI color/style sequences.
* --xprofile <file> Write out an XHProf profile.
* --help Show help.
*
- * @return this
+ * @return $this
*
* @phutil-external-symbol function xhprof_enable
*/
public function parseStandardArguments() {
try {
$this->parsePartial(
array(
array(
'name' => 'trace',
'help' => pht('Trace command execution and show service calls.'),
'standard' => true,
),
array(
'name' => 'no-ansi',
'help' => pht(
'Disable ANSI terminal codes, printing plain text with '.
'no color or style.'),
'conflicts' => array(
'ansi' => null,
),
'standard' => true,
),
array(
'name' => 'ansi',
'help' => pht(
"Use formatting even in environments which probably ".
"don't support it."),
'standard' => true,
),
array(
'name' => 'xprofile',
'param' => 'profile',
'help' => pht(
'Profile script execution and write results to a file.'),
'standard' => true,
),
array(
'name' => 'help',
'short' => 'h',
'help' => pht('Show this help.'),
'standard' => true,
),
array(
'name' => 'show-standard-options',
'help' => pht(
'Show every option, including standard options like this one.'),
'standard' => true,
),
array(
'name' => 'recon',
'help' => pht('Start in remote console mode.'),
'standard' => true,
),
));
} catch (PhutilArgumentUsageException $ex) {
$this->printUsageException($ex);
exit(self::PARSE_ERROR_CODE);
}
if ($this->getArg('trace')) {
PhutilServiceProfiler::installEchoListener();
self::$traceModeEnabled = true;
}
if ($this->getArg('no-ansi')) {
PhutilConsoleFormatter::disableANSI(true);
}
if ($this->getArg('ansi')) {
PhutilConsoleFormatter::disableANSI(false);
}
if ($this->getArg('help')) {
$this->showHelp = true;
}
$xprofile = $this->getArg('xprofile');
if ($xprofile) {
if (!function_exists('xhprof_enable')) {
throw new Exception(
pht('To use "--xprofile", you must install XHProf.'));
}
xhprof_enable(0);
register_shutdown_function(array($this, 'shutdownProfiler'));
}
$recon = $this->getArg('recon');
if ($recon) {
$remote_console = PhutilConsole::newRemoteConsole();
$remote_console->beginRedirectOut();
PhutilConsole::setConsole($remote_console);
} else if ($this->getArg('trace')) {
$server = new PhutilConsoleServer();
$server->setEnableLog(true);
$console = PhutilConsole::newConsoleForServer($server);
PhutilConsole::setConsole($console);
}
return $this;
}
/* -( Reading Arguments )-------------------------------------------------- */
public function getArg($name) {
if (empty($this->specs[$name])) {
throw new PhutilArgumentSpecificationException(
pht('No specification exists for argument "%s"!', $name));
}
if (idx($this->results, $name) !== null) {
return $this->results[$name];
}
return $this->specs[$name]->getDefault();
}
public function getArgAsInteger($name) {
$value = $this->getArg($name);
if ($value === null) {
return $value;
}
if (!preg_match('/^-?\d+\z/', $value)) {
throw new PhutilArgumentUsageException(
pht(
'Parameter provided to argument "--%s" must be an integer.',
$name));
}
$intvalue = (int)$value;
if (phutil_string_cast($intvalue) !== phutil_string_cast($value)) {
throw new PhutilArgumentUsageException(
pht(
'Parameter provided to argument "--%s" is too large to '.
'parse as an integer.',
$name));
}
return $intvalue;
}
public function getUnconsumedArgumentVector() {
return $this->argv;
}
public function setUnconsumedArgumentVector(array $argv) {
$this->argv = $argv;
return $this;
}
public function setWorkflows($workflows) {
$workflows = mpull($workflows, null, 'getName');
$this->workflows = $workflows;
return $this;
}
public function setHelpWorkflows(array $help_workflows) {
$help_workflows = mpull($help_workflows, null, 'getName');
$this->helpWorkflows = $help_workflows;
return $this;
}
public function getWorkflows() {
return $this->workflows;
}
/* -( Command Help )------------------------------------------------------- */
public function setRequireArgumentTerminator($require) {
$this->requireArgumentTerminator = $require;
return $this;
}
public function getRequireArgumentTerminator() {
return $this->requireArgumentTerminator;
}
public function setSynopsis($synopsis) {
$this->synopsis = $synopsis;
return $this;
}
public function setTagline($tagline) {
$this->tagline = $tagline;
return $this;
}
public function printHelpAndExit() {
echo $this->renderHelp();
exit(self::PARSE_ERROR_CODE);
}
public function renderHelp() {
$out = array();
$more = array();
if ($this->bin) {
$out[] = $this->format('**%s**', pht('NAME'));
$name = $this->indent(6, '**%s**', basename($this->bin));
if ($this->tagline) {
$name .= $this->format(' - '.$this->tagline);
}
$out[] = $name;
$out[] = null;
}
if ($this->synopsis) {
$out[] = $this->format('**%s**', pht('SYNOPSIS'));
$out[] = $this->indent(6, $this->synopsis);
$out[] = null;
}
$workflows = $this->helpWorkflows;
if ($workflows === null) {
$workflows = $this->workflows;
}
if ($workflows) {
$has_help = false;
$out[] = $this->format('**%s**', pht('WORKFLOWS'));
$out[] = null;
$flows = $workflows;
ksort($flows);
foreach ($flows as $workflow) {
if ($workflow->getName() == 'help') {
$has_help = true;
}
$out[] = $this->renderWorkflowHelp(
$workflow->getName(),
$show_details = false);
}
if ($has_help) {
$more[] = pht(
'Use **%s** __command__ for a detailed command reference.', 'help');
}
}
$specs = $this->renderArgumentSpecs($this->specs);
if ($specs) {
$out[] = $this->format('**%s**', pht('OPTION REFERENCE'));
$out[] = null;
$out[] = $specs;
}
// If we have standard options but no --show-standard-options, print out
// a quick hint about it.
if (!empty($this->specs['show-standard-options']) &&
!$this->getArg('show-standard-options')) {
$more[] = pht(
'Use __%s__ to show additional options.', '--show-standard-options');
}
$out[] = null;
if ($more) {
foreach ($more as $hint) {
$out[] = $this->indent(0, $hint);
}
$out[] = null;
}
return implode("\n", $out);
}
public function renderWorkflowHelp(
$workflow_name,
$show_details = false) {
$out = array();
$indent = ($show_details ? 0 : 6);
$workflows = $this->helpWorkflows;
if ($workflows === null) {
$workflows = $this->workflows;
}
$workflow = idx($workflows, strtolower($workflow_name));
if (!$workflow) {
$out[] = $this->indent(
$indent,
pht('There is no **%s** workflow.', $workflow_name));
} else {
$out[] = $this->indent($indent, $workflow->getExamples());
$synopsis = $workflow->getSynopsis();
if ($synopsis !== null) {
$out[] = $this->indent($indent, $workflow->getSynopsis());
}
if ($show_details) {
$full_help = $workflow->getHelp();
if ($full_help) {
$out[] = null;
$out[] = $this->indent($indent, $full_help);
}
$specs = $this->renderArgumentSpecs($workflow->getArguments());
if ($specs) {
$out[] = null;
$out[] = $specs;
}
}
}
$out[] = null;
return implode("\n", $out);
}
public function printUsageException(PhutilArgumentUsageException $ex) {
$message = tsprintf(
"**%s** %B\n",
pht('Usage Exception:'),
$ex->getMessage());
$this->logMessage($message);
}
private function logMessage($message) {
PhutilSystem::writeStderr($message);
}
/* -( Internals )---------------------------------------------------------- */
private function filterWildcardArgv(array $argv) {
foreach ($argv as $key => $value) {
if ($value == '--') {
unset($argv[$key]);
break;
} else if (
is_string($value) &&
!strncmp($value, '-', 1) &&
strlen($value) > 1) {
throw new PhutilArgumentUsageException(
pht(
'Argument "%s" is unrecognized. Use "%s" to indicate '.
'the end of flags.',
$value,
'--'));
}
}
return array_values($argv);
}
private function mergeSpecs(array $specs) {
$short_map = mpull($this->specs, null, 'getShortAlias');
unset($short_map[null]);
$wildcard = null;
foreach ($this->specs as $spec) {
if ($spec->getWildcard()) {
$wildcard = $spec;
break;
}
}
foreach ($specs as $spec) {
$spec->validate();
$name = $spec->getName();
if (isset($this->specs[$name])) {
throw new PhutilArgumentSpecificationException(
pht(
'Two argument specifications have the same name ("%s").',
$name));
}
$short = $spec->getShortAlias();
if ($short) {
if (isset($short_map[$short])) {
throw new PhutilArgumentSpecificationException(
pht(
'Two argument specifications have the same short alias ("%s").',
$short));
}
$short_map[$short] = $spec;
}
if ($spec->getWildcard()) {
if ($wildcard) {
throw new PhutilArgumentSpecificationException(
pht(
'Two argument specifications are marked as wildcard arguments. '.
'You can have a maximum of one wildcard argument.'));
} else {
$wildcard = $spec;
}
}
$this->specs[$name] = $spec;
}
foreach ($this->specs as $name => $spec) {
foreach ($spec->getConflicts() as $conflict => $reason) {
if (empty($this->specs[$conflict])) {
throw new PhutilArgumentSpecificationException(
pht(
'Argument "%s" conflicts with unspecified argument "%s".',
$name,
$conflict));
}
if ($conflict == $name) {
throw new PhutilArgumentSpecificationException(
pht(
'Argument "%s" conflicts with itself!',
$name));
}
}
}
}
private function renderArgumentSpecs(array $specs) {
foreach ($specs as $key => $spec) {
if ($spec->getWildcard()) {
unset($specs[$key]);
}
}
$out = array();
$no_standard_options =
!empty($this->specs['show-standard-options']) &&
!$this->getArg('show-standard-options');
$specs = msort($specs, 'getName');
foreach ($specs as $spec) {
if ($spec->getStandard() && $no_standard_options) {
// If this is a standard argument and the user didn't pass
// --show-standard-options, skip it.
continue;
}
$name = $this->indent(6, '__--%s__', $spec->getName());
$short = null;
if ($spec->getShortAlias()) {
$short = $this->format(', __-%s__', $spec->getShortAlias());
}
if ($spec->getParamName()) {
$param = $this->format(' __%s__', $spec->getParamName());
$name .= $param;
if ($short) {
$short .= $param;
}
}
$out[] = $name.$short;
$out[] = $this->indent(10, $spec->getHelp());
$out[] = null;
}
return implode("\n", $out);
}
private function format($str /* , ... */) {
$args = func_get_args();
return call_user_func_array(
'phutil_console_format',
$args);
}
private function indent($level, $str /* , ... */) {
$args = func_get_args();
$args = array_slice($args, 1);
$text = call_user_func_array(array($this, 'format'), $args);
return phutil_console_wrap($text, $level);
}
/**
* @phutil-external-symbol function xhprof_disable
*/
public function shutdownProfiler() {
$data = xhprof_disable();
$data = json_encode($data);
Filesystem::writeFile($this->getArg('xprofile'), $data);
}
public static function isTraceModeEnabled() {
return self::$traceModeEnabled;
}
private function raiseUnknownWorkflow($flow, array $maybe) {
if ($maybe) {
sort($maybe);
$maybe_list = id(new PhutilConsoleList())
->setWrap(false)
->setBullet(null)
->addItems($maybe)
->drawConsoleString();
$message = tsprintf(
"%B\n%B",
pht(
'Invalid command "%s". Did you mean:',
$flow),
$maybe_list);
} else {
$names = mpull($this->workflows, 'getName');
sort($names);
$message = tsprintf(
'%B',
pht(
'Invalid command "%s". Valid commands are: %s.',
$flow,
implode(', ', $names)));
}
if (isset($this->workflows['help'])) {
$binary = basename($this->bin);
$message = tsprintf(
"%B\n%s",
$message,
pht(
'For details on available commands, run "%s".',
"{$binary} help"));
}
throw new PhutilArgumentUsageException($message);
}
private function shouldAutocorrect() {
return !phutil_is_noninteractive();
}
}
diff --git a/src/repository/api/ArcanistRepositoryAPI.php b/src/repository/api/ArcanistRepositoryAPI.php
index dd35db2f..06eba76b 100644
--- a/src/repository/api/ArcanistRepositoryAPI.php
+++ b/src/repository/api/ArcanistRepositoryAPI.php
@@ -1,837 +1,837 @@
<?php
/**
* Interfaces with the VCS in the working copy.
*
* @task status Path Status
*/
abstract class ArcanistRepositoryAPI extends Phobject {
const FLAG_MODIFIED = 1;
const FLAG_ADDED = 2;
const FLAG_DELETED = 4;
const FLAG_UNTRACKED = 8;
const FLAG_CONFLICT = 16;
const FLAG_MISSING = 32;
const FLAG_UNSTAGED = 64;
const FLAG_UNCOMMITTED = 128;
// Occurs in SVN when you have uncommitted changes to a modified external,
// or in Git when you have uncommitted or untracked changes in a submodule.
const FLAG_EXTERNALS = 256;
// Occurs in SVN when you replace a file with a directory without telling
// SVN about it.
const FLAG_OBSTRUCTED = 512;
// Occurs in SVN when an update was interrupted or failed, e.g. you ^C'd it.
const FLAG_INCOMPLETE = 1024;
protected $path;
protected $diffLinesOfContext = 0x7FFF;
private $baseCommitExplanation = '???';
private $configurationManager;
private $baseCommitArgumentRules;
private $uncommittedStatusCache;
private $commitRangeStatusCache;
private $symbolicBaseCommit;
private $resolvedBaseCommit;
private $runtime;
private $currentWorkingCopyStateRef = false;
private $currentCommitRef = false;
private $graph;
abstract public function getSourceControlSystemName();
public function getDiffLinesOfContext() {
return $this->diffLinesOfContext;
}
public function setDiffLinesOfContext($lines) {
$this->diffLinesOfContext = $lines;
return $this;
}
public function getWorkingCopyIdentity() {
return $this->configurationManager->getWorkingCopyIdentity();
}
public function getConfigurationManager() {
return $this->configurationManager;
}
public static function newAPIFromConfigurationManager(
ArcanistConfigurationManager $configuration_manager) {
$working_copy = $configuration_manager->getWorkingCopyIdentity();
if (!$working_copy) {
throw new Exception(
pht(
'Trying to create a %s without a working copy!',
__CLASS__));
}
$root = $working_copy->getProjectRoot();
switch ($working_copy->getVCSType()) {
case 'svn':
$api = new ArcanistSubversionAPI($root);
break;
case 'hg':
$api = new ArcanistMercurialAPI($root);
break;
case 'git':
$api = new ArcanistGitAPI($root);
break;
default:
throw new Exception(
pht(
'The current working directory is not part of a working copy for '.
'a supported version control system (Git, Subversion or '.
'Mercurial).'));
}
$api->configurationManager = $configuration_manager;
return $api;
}
public function __construct($path) {
$this->path = $path;
}
public function getPath($to_file = null) {
if ($to_file !== null) {
return $this->path.DIRECTORY_SEPARATOR.
ltrim($to_file, DIRECTORY_SEPARATOR);
} else {
return $this->path.DIRECTORY_SEPARATOR;
}
}
/* -( Path Status )-------------------------------------------------------- */
abstract protected function buildUncommittedStatus();
abstract protected function buildCommitRangeStatus();
/**
* Get a list of uncommitted paths in the working copy that have been changed
* or are affected by other status effects, like conflicts or untracked
* files.
*
* Convenience methods @{method:getUntrackedChanges},
* @{method:getUnstagedChanges}, @{method:getUncommittedChanges},
* @{method:getMergeConflicts}, and @{method:getIncompleteChanges} allow
* simpler selection of paths in a specific state.
*
* This method returns a map of paths to bitmasks with status, using
* `FLAG_` constants. For example:
*
* array(
* 'some/uncommitted/file.txt' => ArcanistRepositoryAPI::FLAG_UNSTAGED,
* );
*
* A file may be in several states. Not all states are possible with all
* version control systems.
*
* @return map<string, bitmask> Map of paths, see above.
* @task status
*/
final public function getUncommittedStatus() {
if ($this->uncommittedStatusCache === null) {
$status = $this->buildUncommittedStatus();
ksort($status);
$this->uncommittedStatusCache = $status;
}
return $this->uncommittedStatusCache;
}
/**
* @task status
*/
final public function getUntrackedChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_UNTRACKED);
}
/**
* @task status
*/
final public function getUnstagedChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_UNSTAGED);
}
/**
* @task status
*/
final public function getUncommittedChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_UNCOMMITTED);
}
/**
* @task status
*/
final public function getMergeConflicts() {
return $this->getUncommittedPathsWithMask(self::FLAG_CONFLICT);
}
/**
* @task status
*/
final public function getIncompleteChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_INCOMPLETE);
}
/**
* @task status
*/
final public function getMissingChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_MISSING);
}
/**
* @task status
*/
final public function getDirtyExternalChanges() {
return $this->getUncommittedPathsWithMask(self::FLAG_EXTERNALS);
}
/**
* @task status
*/
private function getUncommittedPathsWithMask($mask) {
$match = array();
foreach ($this->getUncommittedStatus() as $path => $flags) {
if ($flags & $mask) {
$match[] = $path;
}
}
return $match;
}
/**
* Get a list of paths affected by the commits in the current commit range.
*
* See @{method:getUncommittedStatus} for a description of the return value.
*
* @return map<string, bitmask> Map from paths to status.
* @task status
*/
final public function getCommitRangeStatus() {
if ($this->commitRangeStatusCache === null) {
$status = $this->buildCommitRangeStatus();
ksort($status);
$this->commitRangeStatusCache = $status;
}
return $this->commitRangeStatusCache;
}
/**
* Get a list of paths affected by commits in the current commit range, or
* uncommitted changes in the working copy. See @{method:getUncommittedStatus}
* or @{method:getCommitRangeStatus} to retrieve smaller parts of the status.
*
* See @{method:getUncommittedStatus} for a description of the return value.
*
* @return map<string, bitmask> Map from paths to status.
* @task status
*/
final public function getWorkingCopyStatus() {
$range_status = $this->getCommitRangeStatus();
$uncommitted_status = $this->getUncommittedStatus();
$result = new PhutilArrayWithDefaultValue($range_status);
foreach ($uncommitted_status as $path => $mask) {
$result[$path] |= $mask;
}
$result = $result->toArray();
ksort($result);
return $result;
}
/**
* Drops caches after changes to the working copy. By default, some queries
* against the working copy are cached. They
*
- * @return this
+ * @return $this
* @task status
*/
final public function reloadWorkingCopy() {
$this->uncommittedStatusCache = null;
$this->commitRangeStatusCache = null;
$this->didReloadWorkingCopy();
$this->reloadCommitRange();
return $this;
}
/**
* Hook for implementations to dirty working copy caches after the working
* copy has been updated.
*
* @return void
* @task status
*/
protected function didReloadWorkingCopy() {
return;
}
/**
* Fetches the original file data for each path provided.
*
* @return map<string, string> Map from path to file data.
*/
public function getBulkOriginalFileData($paths) {
$filedata = array();
foreach ($paths as $path) {
$filedata[$path] = $this->getOriginalFileData($path);
}
return $filedata;
}
/**
* Fetches the current file data for each path provided.
*
* @return map<string, string> Map from path to file data.
*/
public function getBulkCurrentFileData($paths) {
$filedata = array();
foreach ($paths as $path) {
$filedata[$path] = $this->getCurrentFileData($path);
}
return $filedata;
}
/**
* @return Traversable
*/
abstract public function getAllFiles();
abstract public function getBlame($path);
abstract public function getRawDiffText($path);
abstract public function getOriginalFileData($path);
abstract public function getCurrentFileData($path);
abstract public function getLocalCommitInformation();
abstract public function getSourceControlBaseRevision();
abstract public function getCanonicalRevisionName($string);
abstract public function getBranchName();
abstract public function getSourceControlPath();
abstract public function isHistoryDefaultImmutable();
abstract public function supportsAmend();
abstract public function getWorkingCopyRevision();
abstract public function updateWorkingCopy();
abstract public function getMetadataPath();
abstract public function loadWorkingCopyDifferentialRevisions(
ConduitClient $conduit,
array $query);
abstract public function getRemoteURI();
public function getChangedFiles($since_commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getAuthor() {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function addToCommit(array $paths) {
throw new ArcanistCapabilityNotSupportedException($this);
}
abstract public function supportsLocalCommits();
public function doCommit($message) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function amendCommit($message = null) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getBaseCommitRef() {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function hasLocalCommit($commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getCommitMessage($commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getCommitSummary($commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getAllLocalChanges() {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getFinalizedRevisionMessage() {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function execxLocal($pattern /* , ... */) {
$args = func_get_args();
return $this->buildLocalFuture($args)->resolvex();
}
public function execManualLocal($pattern /* , ... */) {
$args = func_get_args();
return $this->buildLocalFuture($args)->resolve();
}
public function execFutureLocal($pattern /* , ... */) {
$args = func_get_args();
return $this->buildLocalFuture($args);
}
abstract protected function buildLocalFuture(array $argv);
public function canStashChanges() {
return false;
}
public function stashChanges() {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function unstashChanges() {
throw new ArcanistCapabilityNotSupportedException($this);
}
/* -( Scratch Files )------------------------------------------------------ */
/**
* Try to read a scratch file, if it exists and is readable.
*
* @param string $path Scratch file name.
* @return mixed String for file contents, or false for failure.
* @task scratch
*/
public function readScratchFile($path) {
$full_path = $this->getScratchFilePath($path);
if (!$full_path) {
return false;
}
if (!Filesystem::pathExists($full_path)) {
return false;
}
try {
$result = Filesystem::readFile($full_path);
} catch (FilesystemException $ex) {
return false;
}
return $result;
}
/**
* Try to write a scratch file, if there's somewhere to put it and we can
* write there.
*
* @param string $path Scratch file name to write.
* @param string $data Data to write.
* @return bool True on success, false on failure.
* @task scratch
*/
public function writeScratchFile($path, $data) {
$dir = $this->getScratchFilePath('');
if (!$dir) {
return false;
}
if (!Filesystem::pathExists($dir)) {
try {
Filesystem::createDirectory($dir);
} catch (Exception $ex) {
return false;
}
}
try {
Filesystem::writeFile($this->getScratchFilePath($path), $data);
} catch (FilesystemException $ex) {
return false;
}
return true;
}
/**
* Try to remove a scratch file.
*
* @param string $path Scratch file name to remove.
* @return bool True if the file was removed successfully.
* @task scratch
*/
public function removeScratchFile($path) {
$full_path = $this->getScratchFilePath($path);
if (!$full_path) {
return false;
}
try {
Filesystem::remove($full_path);
} catch (FilesystemException $ex) {
return false;
}
return true;
}
/**
* Get a human-readable description of the scratch file location.
*
* @param string $path Scratch file name.
* @return mixed String, or false on failure.
* @task scratch
*/
public function getReadableScratchFilePath($path) {
$full_path = $this->getScratchFilePath($path);
if ($full_path) {
return Filesystem::readablePath(
$full_path,
$this->getPath());
} else {
return false;
}
}
/**
* Get the path to a scratch file, if possible.
*
* @param string $path Scratch file name.
* @return mixed File path, or false on failure.
* @task scratch
*/
public function getScratchFilePath($path) {
$new_scratch_path = Filesystem::resolvePath(
'arc',
$this->getMetadataPath());
static $checked = false;
if (!$checked) {
$checked = true;
$old_scratch_path = $this->getPath('.arc');
// we only want to do the migration once
// unfortunately, people have checked in .arc directories which
// means that the old one may get recreated after we delete it
if (Filesystem::pathExists($old_scratch_path) &&
!Filesystem::pathExists($new_scratch_path)) {
Filesystem::createDirectory($new_scratch_path);
$existing_files = Filesystem::listDirectory($old_scratch_path, true);
foreach ($existing_files as $file) {
$new_path = Filesystem::resolvePath($file, $new_scratch_path);
$old_path = Filesystem::resolvePath($file, $old_scratch_path);
Filesystem::writeFile(
$new_path,
Filesystem::readFile($old_path));
}
Filesystem::remove($old_scratch_path);
}
}
return Filesystem::resolvePath($path, $new_scratch_path);
}
/* -( Base Commits )------------------------------------------------------- */
abstract public function supportsCommitRanges();
final public function setBaseCommit($symbolic_commit) {
if (!$this->supportsCommitRanges()) {
throw new ArcanistCapabilityNotSupportedException($this);
}
$this->symbolicBaseCommit = $symbolic_commit;
$this->reloadCommitRange();
return $this;
}
public function setHeadCommit($symbolic_commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
final public function getBaseCommit() {
if (!$this->supportsCommitRanges()) {
throw new ArcanistCapabilityNotSupportedException($this);
}
if ($this->resolvedBaseCommit === null) {
$commit = $this->buildBaseCommit($this->symbolicBaseCommit);
$this->resolvedBaseCommit = $commit;
}
return $this->resolvedBaseCommit;
}
public function getHeadCommit() {
throw new ArcanistCapabilityNotSupportedException($this);
}
final public function reloadCommitRange() {
$this->resolvedBaseCommit = null;
$this->baseCommitExplanation = null;
$this->didReloadCommitRange();
return $this;
}
protected function didReloadCommitRange() {
return;
}
protected function buildBaseCommit($symbolic_commit) {
throw new ArcanistCapabilityNotSupportedException($this);
}
public function getBaseCommitExplanation() {
return $this->baseCommitExplanation;
}
public function setBaseCommitExplanation($explanation) {
$this->baseCommitExplanation = $explanation;
return $this;
}
public function resolveBaseCommitRule($rule, $source) {
return null;
}
public function setBaseCommitArgumentRules($base_commit_argument_rules) {
$this->baseCommitArgumentRules = $base_commit_argument_rules;
return $this;
}
public function getBaseCommitArgumentRules() {
return $this->baseCommitArgumentRules;
}
public function resolveBaseCommit() {
$base_commit_rules = array(
'runtime' => $this->getBaseCommitArgumentRules(),
'local' => '',
'project' => '',
'user' => '',
'system' => '',
);
$all_sources = $this->configurationManager->getConfigFromAllSources('base');
$base_commit_rules = $all_sources + $base_commit_rules;
$parser = new ArcanistBaseCommitParser($this);
$commit = $parser->resolveBaseCommit($base_commit_rules);
return $commit;
}
public function getRepositoryUUID() {
return null;
}
final public function newFuture($pattern /* , ... */) {
$args = func_get_args();
return $this->buildLocalFuture($args)
->setResolveOnError(false);
}
public function newPassthru($pattern /* , ... */) {
throw new PhutilMethodNotImplementedException();
}
final public function execPassthru($pattern /* , ... */) {
$args = func_get_args();
$future = call_user_func_array(
array($this, 'newPassthru'),
$args);
return $future->resolve();
}
final public function setRuntime(ArcanistRuntime $runtime) {
$this->runtime = $runtime;
return $this;
}
final public function getRuntime() {
return $this->runtime;
}
final protected function getSymbolEngine() {
return $this->getRuntime()->getSymbolEngine();
}
final public function getCurrentWorkingCopyStateRef() {
if ($this->currentWorkingCopyStateRef === false) {
$ref = $this->newCurrentWorkingCopyStateRef();
$this->currentWorkingCopyStateRef = $ref;
}
return $this->currentWorkingCopyStateRef;
}
protected function newCurrentWorkingCopyStateRef() {
$commit_ref = $this->getCurrentCommitRef();
if (!$commit_ref) {
return null;
}
return id(new ArcanistWorkingCopyStateRef())
->setCommitRef($commit_ref);
}
final public function getCurrentCommitRef() {
if ($this->currentCommitRef === false) {
$this->currentCommitRef = $this->newCurrentCommitRef();
}
return $this->currentCommitRef;
}
protected function newCurrentCommitRef() {
$symbols = $this->getSymbolEngine();
$commit_symbol = $this->newCurrentCommitSymbol();
return $symbols->loadCommitForSymbol($commit_symbol);
}
protected function newCurrentCommitSymbol() {
throw new ArcanistCapabilityNotSupportedException($this);
}
final public function newCommitRef() {
return new ArcanistCommitRef();
}
final public function newMarkerRef() {
return new ArcanistMarkerRef();
}
final public function getLandEngine() {
$engine = $this->newLandEngine();
if ($engine) {
$engine->setRepositoryAPI($this);
}
return $engine;
}
protected function newLandEngine() {
return null;
}
final public function getWorkEngine() {
$engine = $this->newWorkEngine();
if ($engine) {
$engine->setRepositoryAPI($this);
}
return $engine;
}
protected function newWorkEngine() {
return null;
}
final public function getSupportedMarkerTypes() {
return $this->newSupportedMarkerTypes();
}
protected function newSupportedMarkerTypes() {
return array();
}
final public function newMarkerRefQuery() {
return id($this->newMarkerRefQueryTemplate())
->setRepositoryAPI($this);
}
protected function newMarkerRefQueryTemplate() {
throw new PhutilMethodNotImplementedException();
}
final public function newRemoteRefQuery() {
return id($this->newRemoteRefQueryTemplate())
->setRepositoryAPI($this);
}
protected function newRemoteRefQueryTemplate() {
throw new PhutilMethodNotImplementedException();
}
final public function newCommitGraphQuery() {
return id($this->newCommitGraphQueryTemplate());
}
protected function newCommitGraphQueryTemplate() {
throw new PhutilMethodNotImplementedException();
}
final public function getDisplayHash($hash) {
return substr($hash, 0, 12);
}
final public function getNormalizedURI($uri) {
$normalized_uri = $this->newNormalizedURI($uri);
return $normalized_uri->getNormalizedURI();
}
protected function newNormalizedURI($uri) {
return $uri;
}
final public function getPublishedCommitHashes() {
return $this->newPublishedCommitHashes();
}
protected function newPublishedCommitHashes() {
return array();
}
final public function getGraph() {
if (!$this->graph) {
$this->graph = id(new ArcanistCommitGraph())
->setRepositoryAPI($this);
}
return $this->graph;
}
}
diff --git a/src/symbols/PhutilClassMapQuery.php b/src/symbols/PhutilClassMapQuery.php
index 702ba441..a8bc8080 100644
--- a/src/symbols/PhutilClassMapQuery.php
+++ b/src/symbols/PhutilClassMapQuery.php
@@ -1,338 +1,338 @@
<?php
/**
* Load a map of concrete subclasses of some abstract parent class.
*
* libphutil is extensively modular through runtime introspection of class
* maps. This method makes querying class maps easier.
*
* There are several common patterns used with modular class maps:
*
* - A `getUniqueKey()` method which returns a unique scalar key identifying
* the class.
* - An `expandVariants()` method which potentially returns multiple
* instances of the class with different configurations.
* - A `getSortName()` method which sorts results.
* - Caching of the map.
*
* This class provides support for these mechanisms.
*
* Using the unique key mechanism with @{method:setUniqueMethod} allows you to
* use a more human-readable, storage-friendly key to identify objects, allows
* classes to be freely renamed, and enables variant expansion.
*
* Using the expansion mechanism with @{method:setExpandMethod} allows you to
* have multiple similar modular instances, or configuration-driven instances.
*
* Even if they have no immediate need for either mechanism, class maps should
* generally provide unique keys in their initial design so they are more
* flexible later on. Adding unique keys later can require database migrations,
* while adding an expansion mechanism is trivial if a class map already has
* unique keys.
*
* This class automatically caches class maps and does not need to be wrapped
* in caching logic.
*
* @task config Configuring the Query
* @task exec Executing the Query
* @task cache Managing the Map Cache
*/
final class PhutilClassMapQuery extends Phobject {
private $ancestorClass;
private $expandMethod;
private $filterMethod;
private $filterNull = false;
private $uniqueMethod;
private $sortMethod;
private $continueOnFailure;
// NOTE: If you add more configurable properties here, make sure that
// cache key construction in getCacheKey() is updated properly.
private static $cache = array();
/* -( Configuring the Query )---------------------------------------------- */
/**
* Set the ancestor class or interface name to load the concrete descendants
* of.
*
* @param string $class Ancestor class or interface name.
- * @return this
+ * @return $this
* @task config
*/
public function setAncestorClass($class) {
$this->ancestorClass = $class;
return $this;
}
/**
* Provide a method to select a unique key for each instance.
*
* If you provide a method here, the map will be keyed with these values,
* instead of with class names. Exceptions will be raised if entries are
* not unique.
*
* You must provide a method here to use @{method:setExpandMethod}.
*
* @param string $unique_method Name of the unique key method.
* @param bool $filter_null (optional) If true, then classes which return
* `null` will be filtered from the results.
- * @return this
+ * @return $this
* @task config
*/
public function setUniqueMethod($unique_method, $filter_null = false) {
$this->uniqueMethod = $unique_method;
$this->filterNull = $filter_null;
return $this;
}
/**
* Provide a method to expand each concrete subclass into available instances.
*
* With some class maps, each class is allowed to provide multiple entries
* in the map by returning alternatives from some method with a default
* implementation like this:
*
* public function generateVariants() {
* return array($this);
* }
*
* For example, a "color" class may really generate and configure several
* instances in the final class map:
*
* public function generateVariants() {
* return array(
* self::newColor('red'),
* self::newColor('green'),
* self::newColor('blue'),
* );
* }
*
* This allows multiple entires in the final map to share an entire
* implementation, rather than requiring that they each have their own unique
* subclass.
*
* This pattern is most useful if several variants are nearly identical (so
* the stub subclasses would be essentially empty) or the available variants
* are driven by configuration.
*
* If a class map uses this pattern, it must also provide a unique key for
* each instance with @{method:setUniqueMethod}.
*
* @param string $expand_method Name of the expansion method.
- * @return this
+ * @return $this
* @task config
*/
public function setExpandMethod($expand_method) {
$this->expandMethod = $expand_method;
return $this;
}
/**
* Provide a method to sort the final map.
*
* The map will be sorted using @{function:msort} and passing this method
* name.
*
* @param string $sort_method Name of the sorting method.
- * @return this
+ * @return $this
* @task config
*/
public function setSortMethod($sort_method) {
$this->sortMethod = $sort_method;
return $this;
}
/**
* Provide a method to filter the map.
*
* @param string $filter_method Name of the filtering method.
- * @return this
+ * @return $this
* @task config
*/
public function setFilterMethod($filter_method) {
$this->filterMethod = $filter_method;
return $this;
}
public function setContinueOnFailure($continue) {
$this->continueOnFailure = $continue;
return $this;
}
/* -( Executing the Query )------------------------------------------------ */
/**
* Execute the query as configured.
*
* @return map<string, object> Realized class map.
* @task exec
*/
public function execute() {
$cache_key = $this->getCacheKey();
if (!isset(self::$cache[$cache_key])) {
self::$cache[$cache_key] = $this->loadMap();
}
return self::$cache[$cache_key];
}
/**
* Delete all class map caches.
*
* @return void
* @task exec
*/
public static function deleteCaches() {
self::$cache = array();
}
/**
* Generate the core query results.
*
* This method is used to fill the cache.
*
* @return map<string, object> Realized class map.
* @task exec
*/
private function loadMap() {
$ancestor = $this->ancestorClass;
if (!strlen($ancestor)) {
throw new PhutilInvalidStateException('setAncestorClass');
}
if (!class_exists($ancestor) && !interface_exists($ancestor)) {
throw new Exception(
pht(
'Trying to execute a class map query for descendants of class '.
'"%s", but no such class or interface exists.',
$ancestor));
}
$expand = $this->expandMethod;
$filter = $this->filterMethod;
$unique = $this->uniqueMethod;
$sort = $this->sortMethod;
if ($expand !== null) {
if ($unique === null) {
throw new Exception(
pht(
'Trying to execute a class map query for descendants of class '.
'"%s", but the query specifies an "expand method" ("%s") without '.
'specifying a "unique method". Class maps which support expansion '.
'must have unique keys.',
$ancestor,
$expand));
}
}
$objects = id(new PhutilSymbolLoader())
->setAncestorClass($ancestor)
->setContinueOnFailure($this->continueOnFailure)
->loadObjects();
// Apply the "expand" mechanism, if it is configured.
if ($expand !== null) {
$list = array();
foreach ($objects as $object) {
foreach (call_user_func(array($object, $expand)) as $instance) {
$list[] = $instance;
}
}
} else {
$list = $objects;
}
// Apply the "unique" mechanism, if it is configured.
if ($unique !== null) {
$map = array();
foreach ($list as $object) {
$key = call_user_func(array($object, $unique));
if ($key === null && $this->filterNull) {
continue;
}
if (empty($map[$key])) {
$map[$key] = $object;
continue;
}
throw new Exception(
pht(
'Two objects (of classes "%s" and "%s", descendants of ancestor '.
'class "%s") returned the same key from "%s" ("%s"), but each '.
'object in this class map must be identified by a unique key.',
get_class($object),
get_class($map[$key]),
$ancestor,
$unique.'()',
$key));
}
} else {
$map = $list;
}
// Apply the "filter" mechanism, if it is configured.
if ($filter !== null) {
$map = mfilter($map, $filter);
}
// Apply the "sort" mechanism, if it is configured.
if ($sort !== null) {
if ($map) {
// The "sort" method may return scalars (which we want to sort with
// "msort()"), or may return PhutilSortVector objects (which we want
// to sort with "msortv()").
$item = call_user_func(array(head($map), $sort));
// Since we may be early in the stack, use a string to avoid triggering
// autoload in old versions of PHP.
$vector_class = 'PhutilSortVector';
if ($item instanceof $vector_class) {
$map = msortv($map, $sort);
} else {
$map = msort($map, $sort);
}
}
}
return $map;
}
/* -( Managing the Map Cache )--------------------------------------------- */
/**
* Return a cache key for this query.
*
* @return string Cache key.
* @task cache
*/
public function getCacheKey() {
$parts = array(
$this->ancestorClass,
$this->uniqueMethod,
$this->filterNull,
$this->expandMethod,
$this->filterMethod,
$this->sortMethod,
);
return implode(':', $parts);
}
}
diff --git a/src/symbols/PhutilSymbolLoader.php b/src/symbols/PhutilSymbolLoader.php
index 795b0803..12566a2f 100644
--- a/src/symbols/PhutilSymbolLoader.php
+++ b/src/symbols/PhutilSymbolLoader.php
@@ -1,462 +1,462 @@
<?php
/**
* Query and load Phutil classes, interfaces and functions.
*
* `PhutilSymbolLoader` is a query object which selects symbols which satisfy
* certain criteria, and optionally loads them. For instance, to load all
* classes in a library:
*
* ```lang=php
* $symbols = id(new PhutilSymbolLoader())
* ->setType('class')
* ->setLibrary('example')
* ->selectAndLoadSymbols();
* ```
*
* When you execute the loading query, it returns a dictionary of matching
* symbols:
*
* ```lang=php
* array(
* 'class$Example' => array(
* 'type' => 'class',
* 'name' => 'Example',
* 'library' => 'libexample',
* 'where' => 'examples/example.php',
* ),
* // ... more ...
* );
* ```
*
* The **library** and **where** keys show where the symbol is defined. The
* **type** and **name** keys identify the symbol itself.
*
* NOTE: This class must not use libphutil functions, including @{function:id}
* and @{function:idx}.
*
* @task config Configuring the Query
* @task load Loading Symbols
* @task internal Internals
*/
final class PhutilSymbolLoader {
private $type;
private $library;
private $base;
private $name;
private $concrete;
private $pathPrefix;
private $suppressLoad;
private $continueOnFailure;
/**
* Select the type of symbol to load, either `class`, `function` or
* `interface`.
*
* @param string $type Type of symbol to load.
- * @return this
+ * @return $this
*
* @task config
*/
public function setType($type) {
$this->type = $type;
return $this;
}
/**
* Restrict the symbol query to a specific library; only symbols from this
* library will be loaded.
*
* @param string $library Library name.
- * @return this
+ * @return $this
*
* @task config
*/
public function setLibrary($library) {
// Validate the library name; this throws if the library in not loaded.
$bootloader = PhutilBootloader::getInstance();
$bootloader->getLibraryRoot($library);
$this->library = $library;
return $this;
}
/**
* Restrict the symbol query to a specific path prefix; only symbols defined
* in files below that path will be selected.
*
* @param string $path Path relative to library root, like "apps/cheese/".
- * @return this
+ * @return $this
*
* @task config
*/
public function setPathPrefix($path) {
$this->pathPrefix = str_replace(DIRECTORY_SEPARATOR, '/', $path);
return $this;
}
/**
* Restrict the symbol query to a single symbol name, e.g. a specific class
* or function name.
*
* @param string $name Symbol name.
- * @return this
+ * @return $this
*
* @task config
*/
public function setName($name) {
$this->name = $name;
return $this;
}
/**
* Restrict the symbol query to only descendants of some class. This will
* strictly select descendants, the base class will not be selected. This
* implies loading only classes.
*
* @param string $base Base class name.
- * @return this
+ * @return $this
*
* @task config
*/
public function setAncestorClass($base) {
$this->base = $base;
return $this;
}
/**
* Restrict the symbol query to only concrete symbols; this will filter out
* abstract classes.
*
* NOTE: This currently causes class symbols to load, even if you run
* @{method:selectSymbolsWithoutLoading}.
*
* @param bool $concrete True if the query should load only concrete symbols.
- * @return this
+ * @return $this
*
* @task config
*/
public function setConcreteOnly($concrete) {
$this->concrete = $concrete;
return $this;
}
public function setContinueOnFailure($continue) {
$this->continueOnFailure = $continue;
return $this;
}
/* -( Load )--------------------------------------------------------------- */
/**
* Execute the query and select matching symbols, then load them so they can
* be used.
*
* @return dict A dictionary of matching symbols. See top-level class
* documentation for details. These symbols will be loaded
* and available.
*
* @task load
*/
public function selectAndLoadSymbols() {
$map = array();
$bootloader = PhutilBootloader::getInstance();
if ($this->library) {
$libraries = array($this->library);
} else {
$libraries = $bootloader->getAllLibraries();
}
if ($this->type) {
$types = array($this->type);
} else {
$types = array(
'class',
'function',
);
}
$names = null;
if ($this->base) {
$names = $this->selectDescendantsOf(
$bootloader->getClassTree(),
$this->base);
}
$symbols = array();
foreach ($libraries as $library) {
$map = $bootloader->getLibraryMap($library);
foreach ($types as $type) {
if ($type == 'interface') {
$lookup_map = $map['class'];
} else {
$lookup_map = $map[$type];
}
// As an optimization, we filter the list of candidate symbols in
// several passes, applying a name-based filter first if possible since
// it is highly selective and guaranteed to match at most one symbol.
// This is the common case and we land here through `__autoload()` so
// it's worthwhile to micro-optimize a bit because this code path is
// very hot and we save 5-10ms per page for a very moderate increase in
// complexity.
if ($this->name) {
// If we have a name filter, just pick the matching name out if it
// exists.
if (isset($lookup_map[$this->name])) {
$filtered_map = array(
$this->name => $lookup_map[$this->name],
);
} else {
$filtered_map = array();
}
} else if ($names !== null) {
$filtered_map = array();
foreach ($names as $name => $ignored) {
if (isset($lookup_map[$name])) {
$filtered_map[$name] = $lookup_map[$name];
}
}
} else {
// Otherwise, start with everything.
$filtered_map = $lookup_map;
}
if ($this->pathPrefix) {
$len = strlen($this->pathPrefix);
foreach ($filtered_map as $name => $where) {
if (strncmp($where, $this->pathPrefix, $len) !== 0) {
unset($filtered_map[$name]);
}
}
}
foreach ($filtered_map as $name => $where) {
$symbols[$type.'$'.$name] = array(
'type' => $type,
'name' => $name,
'library' => $library,
'where' => $where,
);
}
}
}
if (!$this->suppressLoad) {
// Loading a class may trigger the autoloader to load more classes
// (usually, the parent class), so we need to keep track of whether we
// are currently loading in "continue on failure" mode. Otherwise, we'll
// fail anyway if we fail to load a parent class.
// The driving use case for the "continue on failure" mode is to let
// "arc liberate" run so it can rebuild the library map, even if you have
// made changes to Workflow or Config classes which it must load before
// it can operate. If we don't let it continue on failure, it is very
// difficult to remove or move Workflows.
static $continue_depth = 0;
if ($this->continueOnFailure) {
$continue_depth++;
}
$caught = null;
foreach ($symbols as $key => $symbol) {
try {
$this->loadSymbol($symbol);
} catch (Exception $ex) {
// If we failed to load this symbol, remove it from the results.
// Otherwise, we may fatal below when trying to reflect it.
unset($symbols[$key]);
$caught = $ex;
}
}
$should_continue = ($continue_depth > 0);
if ($this->continueOnFailure) {
$continue_depth--;
}
if ($caught) {
// NOTE: We try to load everything even if we fail to load something,
// primarily to make it possible to remove functions from a libphutil
// library without breaking library startup.
if ($should_continue) {
// We may not have `pht()` yet.
$message = sprintf(
"%s: %s\n",
'IGNORING CLASS LOAD FAILURE',
$caught->getMessage());
@file_put_contents('php://stderr', $message);
} else {
throw $caught;
}
}
}
if ($this->concrete) {
// Remove 'abstract' classes.
foreach ($symbols as $key => $symbol) {
if ($symbol['type'] == 'class') {
$reflection = new ReflectionClass($symbol['name']);
if ($reflection->isAbstract()) {
unset($symbols[$key]);
}
}
}
}
return $symbols;
}
/**
* Execute the query and select matching symbols, but do not load them. This
* will perform slightly better if you are only interested in the existence
* of the symbols and don't plan to use them; otherwise, use
* @{method:selectAndLoadSymbols}.
*
* @return dict A dictionary of matching symbols. See top-level class
* documentation for details.
*
* @task load
*/
public function selectSymbolsWithoutLoading() {
$this->suppressLoad = true;
$result = $this->selectAndLoadSymbols();
$this->suppressLoad = false;
return $result;
}
/**
* Select symbols matching the query and then instantiate them, returning
* concrete objects. This is a convenience method which simplifies symbol
* handling if you are only interested in building objects.
*
* If you want to do more than build objects, or want to build objects with
* varying constructor arguments, use @{method:selectAndLoadSymbols} for
* fine-grained control over results.
*
* This method implicitly restricts the query to match only concrete
* classes.
*
* @param list<wild> $argv List of constructor arguments.
* @return map<string, object> Map of class names to constructed objects.
*/
public function loadObjects(array $argv = array()) {
$symbols = $this
->setConcreteOnly(true)
->setType('class')
->selectAndLoadSymbols();
$objects = array();
foreach ($symbols as $symbol) {
$objects[$symbol['name']] = newv($symbol['name'], $argv);
}
return $objects;
}
/* -( Internals )---------------------------------------------------------- */
/**
* @task internal
*/
private function selectDescendantsOf(array $tree, $root) {
$result = array();
if (empty($tree[$root])) {
// No known descendants.
return array();
}
foreach ($tree[$root] as $child) {
$result[$child] = true;
if (!empty($tree[$child])) {
$result += $this->selectDescendantsOf($tree, $child);
}
}
return $result;
}
private static function classLikeExists($name) {
return class_exists($name, false) ||
interface_exists($name, false) ||
trait_exists($name, false);
}
/**
* @task internal
*/
private function loadSymbol(array $symbol_spec) {
// Check if we've already loaded the symbol; bail if we have.
$name = $symbol_spec['name'];
$is_function = ($symbol_spec['type'] == 'function');
if ($is_function) {
if (function_exists($name)) {
return;
}
} else {
if (self::classLikeExists($name)) {
return;
}
}
$lib_name = $symbol_spec['library'];
$where = $symbol_spec['where'];
$bootloader = PhutilBootloader::getInstance();
$bootloader->loadLibrarySource($lib_name, $where);
// Check that we successfully loaded the symbol from wherever it was
// supposed to be defined.
$load_failed = null;
if ($is_function) {
if (!function_exists($name)) {
$load_failed = pht('function');
}
} else {
if (!self::classLikeExists($name)) {
$load_failed = pht('class or interface');
}
}
if ($load_failed !== null) {
$lib_path = phutil_get_library_root($lib_name);
throw new PhutilMissingSymbolException(
$name,
$load_failed,
pht(
"The symbol map for library '%s' (at '%s') claims this %s is ".
"defined in '%s', but loading that source file did not cause the ".
"%s to become defined.",
$lib_name,
$lib_path,
$load_failed,
$where,
$load_failed));
}
}
}
diff --git a/src/unit/ArcanistUnitTestResult.php b/src/unit/ArcanistUnitTestResult.php
index fb26ee9d..07a1d0d1 100644
--- a/src/unit/ArcanistUnitTestResult.php
+++ b/src/unit/ArcanistUnitTestResult.php
@@ -1,242 +1,242 @@
<?php
/**
* Represents the outcome of running a unit test.
*/
final class ArcanistUnitTestResult extends Phobject {
const RESULT_PASS = 'pass';
const RESULT_FAIL = 'fail';
const RESULT_SKIP = 'skip';
const RESULT_BROKEN = 'broken';
const RESULT_UNSOUND = 'unsound';
private $namespace;
private $name;
private $link;
private $result;
private $duration;
private $userData;
private $extraData;
private $coverage;
public function setNamespace($namespace) {
$this->namespace = $namespace;
return $this;
}
public function getNamespace() {
return $this->namespace;
}
public function setName($name) {
$maximum_bytes = 255;
$actual_bytes = strlen($name);
if ($actual_bytes > $maximum_bytes) {
throw new Exception(
pht(
'Parameter ("%s") passed to "%s" when constructing a unit test '.
'message must be a string with a maximum length of %s bytes, but '.
'is %s bytes in length.',
$name,
'setName()',
new PhutilNumber($maximum_bytes),
new PhutilNumber($actual_bytes)));
}
$this->name = $name;
return $this;
}
public function getName() {
return $this->name;
}
public function setLink($link) {
$this->link = $link;
return $this;
}
public function getLink() {
return $this->link;
}
public function setResult($result) {
$this->result = $result;
return $this;
}
public function getResult() {
return $this->result;
}
/**
* Set the number of seconds spent executing this test.
*
* Reporting this information can help users identify slow tests and reduce
* the total cost of running a test suite.
*
* Callers should pass an integer or a float. For example, pass `3` for
* 3 seconds, or `0.125` for 125 milliseconds.
*
* @param int|float $duration Duration, in seconds.
- * @return this
+ * @return $this
*/
public function setDuration($duration) {
if (!is_int($duration) && !is_float($duration)) {
throw new Exception(
pht(
'Parameter passed to setDuration() must be an integer or a float.'));
}
$this->duration = $duration;
return $this;
}
public function getDuration() {
return $this->duration;
}
public function setUserData($user_data) {
$this->userData = $user_data;
return $this;
}
public function getUserData() {
return $this->userData;
}
/**
* "extra data" allows an implementation to store additional key/value
* metadata along with the result of the test run.
*/
public function setExtraData(array $extra_data = null) {
$this->extraData = $extra_data;
return $this;
}
public function getExtraData() {
return $this->extraData;
}
public function setCoverage($coverage) {
$this->coverage = $coverage;
return $this;
}
public function getCoverage() {
return $this->coverage;
}
/**
* Merge several coverage reports into a comprehensive coverage report.
*
* @param list $coverage List of coverage report strings.
* @return string|null Cumulative coverage report, or null if $coverage is
* null.
*/
public static function mergeCoverage(array $coverage) {
if (empty($coverage)) {
return null;
}
$base = reset($coverage);
foreach ($coverage as $more_coverage) {
$base_len = strlen($base);
$more_len = strlen($more_coverage);
$len = min($base_len, $more_len);
for ($ii = 0; $ii < $len; $ii++) {
if ($more_coverage[$ii] == 'C') {
$base[$ii] = 'C';
}
}
// If a secondary report has more data, copy all of it over.
if ($more_len > $base_len) {
$base .= substr($more_coverage, $base_len);
}
}
return $base;
}
public function toDictionary() {
return array(
'namespace' => $this->getNamespace(),
'name' => $this->getName(),
'link' => $this->getLink(),
'result' => $this->getResult(),
'duration' => $this->getDuration(),
'extra' => $this->getExtraData(),
'userData' => $this->getUserData(),
'coverage' => $this->getCoverage(),
);
}
public static function getAllResultCodes() {
return array(
self::RESULT_PASS,
self::RESULT_FAIL,
self::RESULT_SKIP,
self::RESULT_BROKEN,
self::RESULT_UNSOUND,
);
}
public static function getResultCodeName($result_code) {
$spec = self::getResultCodeSpec($result_code);
if (!$spec) {
return null;
}
return idx($spec, 'name');
}
public static function getResultCodeDescription($result_code) {
$spec = self::getResultCodeSpec($result_code);
if (!$spec) {
return null;
}
return idx($spec, 'description');
}
private static function getResultCodeSpec($result_code) {
$specs = self::getResultCodeSpecs();
return idx($specs, $result_code);
}
private static function getResultCodeSpecs() {
return array(
self::RESULT_PASS => array(
'name' => pht('Pass'),
'description' => pht(
'The test passed.'),
),
self::RESULT_FAIL => array(
'name' => pht('Fail'),
'description' => pht(
'The test failed.'),
),
self::RESULT_SKIP => array(
'name' => pht('Skip'),
'description' => pht(
'The test was not executed.'),
),
self::RESULT_BROKEN => array(
'name' => pht('Broken'),
'description' => pht(
'The test failed in an abnormal or severe way. For example, the '.
'harness crashed instead of reporting a failure.'),
),
self::RESULT_UNSOUND => array(
'name' => pht('Unsound'),
'description' => pht(
'The test failed, but this change is probably not what broke it. '.
'For example, it might have already been failing.'),
),
);
}
}
diff --git a/src/upload/ArcanistFileDataRef.php b/src/upload/ArcanistFileDataRef.php
index c1aa4d56..c3e6adcb 100644
--- a/src/upload/ArcanistFileDataRef.php
+++ b/src/upload/ArcanistFileDataRef.php
@@ -1,368 +1,368 @@
<?php
/**
* Reference to a file or block of file data which can be uploaded using
* @{class:ArcanistFileUploader}.
*
* You can either upload a file on disk by using @{method:setPath}, or upload
* a block of data in memory by using @{method:setData}.
*
* For usage examples, see @{class:ArcanistFileUploader}.
*
* After uploading, successful uploads will have @{method:getPHID} populated.
* Failed uploads will have @{method:getErrors} populated with a description
* of reasons for failure.
*
* @task config Configuring File References
* @task results Handling Upload Results
* @task uploader Uploader API
*/
final class ArcanistFileDataRef extends Phobject {
private $name;
private $data;
private $path;
private $hash;
private $size;
private $errors = array();
private $phid;
private $fileHandle;
private $deleteAfterEpoch;
private $viewPolicy;
/* -( Configuring File References )---------------------------------------- */
/**
* Set a human-readable display filename, like "file.jpg".
*
* This name does not correspond to a path on disk, and is purely for
* human consumption.
*
* @param string $name Filename.
- * @return this
+ * @return $this
* @task config
*/
public function setName($name) {
$this->name = $name;
return $this;
}
/**
* @task config
*/
public function getName() {
return $this->name;
}
/**
* Set the data to upload as a single raw blob.
*
* You can specify file data by calling this method with a single blob of
* data, or by calling @{method:setPath} and providing a path to a file on
* disk.
*
* @param bytes $data Blob of file data.
* @task config
*/
public function setData($data) {
$this->data = $data;
return $this;
}
/**
* @task config
*/
public function getData() {
return $this->data;
}
/**
* Set the data to upload by pointing to a file on disk.
*
* You can specify file data by calling this method with a path, or by
* providing a blob of raw data to @{method:setData}.
*
* The path itself only provides data. If you want to name the file, you
* should also call @{method:setName}.
*
* @param string $path Path on disk to a file containing data to upload.
- * @return this
+ * @return $this
* @task config
*/
public function setPath($path) {
$this->path = $path;
return $this;
}
/**
* @task config
*/
public function getPath() {
return $this->path;
}
/**
* @task config
*/
public function setViewPolicy($view_policy) {
$this->viewPolicy = $view_policy;
return $this;
}
/**
* @task config
*/
public function getViewPolicy() {
return $this->viewPolicy;
}
/**
* Configure a file to be temporary instead of permanent.
*
* By default, files are retained indefinitely until explicitly deleted. If
* you want to upload a temporary file instead, you can specify an epoch
* timestamp. The file will be deleted after this time.
*
* @param int $epoch Epoch timestamp to retain the file until.
- * @return this
+ * @return $this
* @task config
*/
public function setDeleteAfterEpoch($epoch) {
$this->deleteAfterEpoch = $epoch;
return $this;
}
/**
* @task config
*/
public function getDeleteAfterEpoch() {
return $this->deleteAfterEpoch;
}
/* -( Handling Upload Results )-------------------------------------------- */
/**
* @task results
*/
public function getErrors() {
return $this->errors;
}
/**
* @task results
*/
public function getPHID() {
return $this->phid;
}
/* -( Uploader API )------------------------------------------------------- */
/**
* @task uploader
*/
public function willUpload() {
$have_data = ($this->data !== null);
$have_path = ($this->path !== null);
if (!$have_data && !$have_path) {
throw new Exception(
pht(
'Specify setData() or setPath() when building a file data '.
'reference.'));
}
if ($have_data && $have_path) {
throw new Exception(
pht(
'Specify either setData() or setPath() when building a file data '.
'reference, but not both.'));
}
if ($have_path) {
$path = $this->path;
if (!Filesystem::pathExists($path)) {
throw new Exception(
pht(
'Unable to upload file: path "%s" does not exist.',
$path));
}
try {
Filesystem::assertIsFile($path);
} catch (FilesystemException $ex) {
throw new Exception(
pht(
'Unable to upload file: path "%s" is not a file.',
$path));
}
try {
Filesystem::assertReadable($path);
} catch (FilesystemException $ex) {
throw new Exception(
pht(
'Unable to upload file: path "%s" is not readable.',
$path));
}
$size = @filesize($path);
if ($size === false) {
throw new Exception(
pht(
'Unable to upload file: failed to determine filesize of '.
'path "%s".',
$path));
}
$this->hash = $this->newFileHash($path);
$this->size = $size;
} else {
$data = $this->data;
$this->hash = $this->newDataHash($data);
$this->size = strlen($data);
}
}
/**
* @task uploader
*/
public function didFail($error) {
$this->errors[] = $error;
return $this;
}
/**
* @task uploader
*/
public function setPHID($phid) {
$this->phid = $phid;
return $this;
}
/**
* @task uploader
*/
public function getByteSize() {
if ($this->size === null) {
throw new PhutilInvalidStateException('willUpload');
}
return $this->size;
}
/**
* @task uploader
*/
public function getContentHash() {
if ($this->size === null) {
throw new PhutilInvalidStateException('willUpload');
}
return $this->hash;
}
/**
* @task uploader
*/
public function didUpload() {
if ($this->fileHandle) {
@fclose($this->fileHandle);
$this->fileHandle = null;
}
}
/**
* @task uploader
*/
public function readBytes($start, $end) {
if ($this->size === null) {
throw new PhutilInvalidStateException('willUpload');
}
$len = ($end - $start);
if ($this->data !== null) {
return substr($this->data, $start, $len);
}
$path = $this->path;
if ($this->fileHandle === null) {
$f = @fopen($path, 'rb');
if (!$f) {
throw new Exception(
pht(
'Unable to upload file: failed to open path "%s" for reading.',
$path));
}
$this->fileHandle = $f;
}
$f = $this->fileHandle;
$ok = @fseek($f, $start);
if ($ok !== 0) {
throw new Exception(
pht(
'Unable to upload file: failed to fseek() to offset %d in file '.
'at path "%s".',
$start,
$path));
}
$data = @fread($f, $len);
if ($data === false) {
throw new Exception(
pht(
'Unable to upload file: failed to read %d bytes after offset %d '.
'from file at path "%s".',
$len,
$start,
$path));
}
return $data;
}
private function newFileHash($path) {
$hash = hash_file('sha256', $path, $raw_output = false);
if ($hash === false) {
return null;
}
return $hash;
}
private function newDataHash($data) {
$hash = hash('sha256', $data, $raw_output = false);
if ($hash === false) {
return null;
}
return $hash;
}
}
diff --git a/src/upload/ArcanistFileUploader.php b/src/upload/ArcanistFileUploader.php
index cc4217be..4c4b32cd 100644
--- a/src/upload/ArcanistFileUploader.php
+++ b/src/upload/ArcanistFileUploader.php
@@ -1,319 +1,319 @@
<?php
/**
* Upload a list of @{class:ArcanistFileDataRef} objects over Conduit.
*
* // Create a new uploader.
* $uploader = id(new ArcanistFileUploader())
* ->setConduitEngine($conduit);
*
* // Queue one or more files to be uploaded.
* $file = id(new ArcanistFileDataRef())
* ->setName('example.jpg')
* ->setPath('/path/to/example.jpg');
* $uploader->addFile($file);
*
* // Upload the files.
* $files = $uploader->uploadFiles();
*
* For details about building file references, see @{class:ArcanistFileDataRef}.
*
* @task config Configuring the Uploader
* @task add Adding Files
* @task upload Uploading Files
* @task internal Internals
*/
final class ArcanistFileUploader extends Phobject {
private $conduitEngine;
private $files = array();
/* -( Configuring the Uploader )------------------------------------------- */
public function setConduitEngine(ArcanistConduitEngine $engine) {
$this->conduitEngine = $engine;
return $this;
}
/* -( Adding Files )------------------------------------------------------- */
/**
* Add a file to the list of files to be uploaded.
*
* You can optionally provide an explicit key which will be used to identify
* the file. After adding files, upload them with @{method:uploadFiles}.
*
* @param ArcanistFileDataRef $file File data to upload.
* @param null|string $key (optional) Key to use to identify this file.
- * @return this
+ * @return $this
* @task add
*/
public function addFile(ArcanistFileDataRef $file, $key = null) {
if ($key === null) {
$this->files[] = $file;
} else {
if (isset($this->files[$key])) {
throw new Exception(
pht(
'Two files were added with identical explicit keys ("%s"); each '.
'explicit key must be unique.',
$key));
}
$this->files[$key] = $file;
}
return $this;
}
/* -( Uploading Files )---------------------------------------------------- */
/**
* Upload files to the server.
*
* This transfers all files which have been queued with @{method:addFiles}
* over the Conduit link configured with @{method:setConduitEngine}.
*
* This method returns a map of all file data references. If references were
* added with an explicit key when @{method:addFile} was called, the key is
* retained in the result map.
*
* On return, files are either populated with a PHID (indicating a successful
* upload) or a list of errors. See @{class:ArcanistFileDataRef} for
* details.
*
* @return map<string, ArcanistFileDataRef> Files with results populated.
* @task upload
*/
public function uploadFiles() {
if (!$this->conduitEngine) {
throw new PhutilInvalidStateException('setConduitEngine');
}
$files = $this->files;
foreach ($files as $key => $file) {
try {
$file->willUpload();
} catch (Exception $ex) {
$file->didFail($ex->getMessage());
unset($files[$key]);
}
}
$conduit = $this->conduitEngine;
$futures = array();
foreach ($files as $key => $file) {
$params = $this->getUploadParameters($file) + array(
'contentLength' => $file->getByteSize(),
'contentHash' => $file->getContentHash(),
);
$delete_after = $file->getDeleteAfterEpoch();
if ($delete_after !== null) {
$params['deleteAfterEpoch'] = $delete_after;
}
$futures[$key] = $conduit->newFuture('file.allocate', $params);
}
$iterator = id(new FutureIterator($futures))->limit(4);
$chunks = array();
foreach ($iterator as $key => $future) {
try {
$result = $future->resolve();
} catch (Exception $ex) {
// The most likely cause for a failure here is that the server does
// not support `file.allocate`. In this case, we'll try the older
// upload method below.
continue;
}
$phid = $result['filePHID'];
$file = $files[$key];
// We don't need to upload any data. Figure out why not: this can either
// be because of an error (server can't accept the data) or because the
// server already has the data.
if (!$result['upload']) {
if (!$phid) {
$file->didFail(
pht(
'Unable to upload file: the server refused to accept file '.
'"%s". This usually means it is too large.',
$file->getName()));
} else {
// These server completed the upload by creating a reference to known
// file data. We don't need to transfer the actual data, and are all
// set.
$file->setPHID($phid);
}
unset($files[$key]);
continue;
}
// The server wants us to do an upload.
if ($phid) {
$chunks[$key] = array(
'file' => $file,
'phid' => $phid,
);
}
}
foreach ($chunks as $key => $chunk) {
$file = $chunk['file'];
$phid = $chunk['phid'];
try {
$this->uploadChunks($file, $phid);
$file->setPHID($phid);
} catch (Exception $ex) {
$file->didFail(
pht(
'Unable to upload file chunks: %s',
$ex->getMessage()));
}
unset($files[$key]);
}
foreach ($files as $key => $file) {
try {
$phid = $this->uploadData($file);
$file->setPHID($phid);
} catch (Exception $ex) {
$file->didFail(
pht(
'Unable to upload file data: %s',
$ex->getMessage()));
}
unset($files[$key]);
}
foreach ($this->files as $file) {
$file->didUpload();
}
return $this->files;
}
/* -( Internals )---------------------------------------------------------- */
/**
* Upload missing chunks of a large file by calling `file.uploadchunk` over
* Conduit.
*
* @task internal
*/
private function uploadChunks(ArcanistFileDataRef $file, $file_phid) {
$conduit = $this->conduitEngine;
$future = $conduit->newFuture(
'file.querychunks',
array(
'filePHID' => $file_phid,
));
$chunks = $future->resolve();
$remaining = array();
foreach ($chunks as $chunk) {
if (!$chunk['complete']) {
$remaining[] = $chunk;
}
}
$done = (count($chunks) - count($remaining));
if ($done) {
$this->writeStatus(
pht(
'Resuming upload (%s of %s chunks remain).',
phutil_count($remaining),
phutil_count($chunks)));
} else {
$this->writeStatus(
pht(
'Uploading chunks (%s chunks to upload).',
phutil_count($remaining)));
}
$progress = new PhutilConsoleProgressBar();
$progress->setTotal(count($chunks));
for ($ii = 0; $ii < $done; $ii++) {
$progress->update(1);
}
$progress->draw();
// TODO: We could do these in parallel to improve upload performance.
foreach ($remaining as $chunk) {
$data = $file->readBytes($chunk['byteStart'], $chunk['byteEnd']);
$future = $conduit->newFuture(
'file.uploadchunk',
array(
'filePHID' => $file_phid,
'byteStart' => $chunk['byteStart'],
'dataEncoding' => 'base64',
'data' => base64_encode($data),
));
$future->resolve();
$progress->update(1);
}
}
/**
* Upload an entire file by calling `file.upload` over Conduit.
*
* @task internal
*/
private function uploadData(ArcanistFileDataRef $file) {
$conduit = $this->conduitEngine;
$data = $file->readBytes(0, $file->getByteSize());
$future = $conduit->newFuture(
'file.upload',
$this->getUploadParameters($file) + array(
'data_base64' => base64_encode($data),
));
return $future->resolve();
}
/**
* Get common parameters for file uploads.
*/
private function getUploadParameters(ArcanistFileDataRef $file) {
$params = array(
'name' => $file->getName(),
);
$view_policy = $file->getViewPolicy();
if ($view_policy !== null) {
$params['viewPolicy'] = $view_policy;
}
return $params;
}
/**
* Write a status message.
*
* @task internal
*/
private function writeStatus($message) {
PhutilSystem::writeStderr($message."\n");
}
}
diff --git a/src/utils/AbstractDirectedGraph.php b/src/utils/AbstractDirectedGraph.php
index 4e7db69a..2bd0385d 100644
--- a/src/utils/AbstractDirectedGraph.php
+++ b/src/utils/AbstractDirectedGraph.php
@@ -1,338 +1,338 @@
<?php
/**
* Models a directed graph in a generic way that works well with graphs stored
* in a database, and allows you to perform operations like cycle detection.
*
* To use this class, seed it with a set of edges (e.g., the new candidate
* edges the user is trying to create) using @{method:addNodes}, then
* call @{method:loadGraph} to construct the graph.
*
* $detector = new ExamplePhabricatorGraphCycleDetector();
* $detector->addNodes(
* array(
* $object->getPHID() => $object->getChildPHIDs(),
* ));
* $detector->loadGraph();
*
* Now you can query the graph, e.g. by detecting cycles:
*
* $cycle = $detector->detectCycles($object->getPHID());
*
* If ##$cycle## is empty, no graph cycle is reachable from the node. If it
* is nonempty, it contains a list of nodes which form a graph cycle.
*
* NOTE: Nodes must be represented with scalars.
*
* @task build Graph Construction
* @task cycle Cycle Detection
* @task explore Graph Exploration
*/
abstract class AbstractDirectedGraph extends Phobject {
private $knownNodes = array();
private $graphLoaded = false;
/* -( Graph Construction )------------------------------------------------- */
/**
* Load the edges for a list of nodes. You must override this method. You
* will be passed a list of nodes, and should return a dictionary mapping
* each node to the list of nodes that can be reached by following its the
* edges which originate at it: for example, the child nodes of an object
* which has a parent-child relationship to other objects.
*
* The intent of this method is to allow you to issue a single query per
* graph level for graphs which are stored as edge tables in the database.
* Generally, you will load all the objects which correspond to the list of
* nodes, and then return a map from each of their IDs to all their children.
*
* NOTE: You must return an entry for every node you are passed, even if it
* is invalid or can not be loaded. Either return an empty array (if this is
* acceptable for your application) or throw an exception if you can't satisfy
* this requirement.
*
* @param list $nodes A list of nodes.
* @return dict A map of nodes to the nodes reachable along their edges.
* There must be an entry for each node you were provided.
* @task build
*/
abstract protected function loadEdges(array $nodes);
/**
* Seed the graph with known nodes. Often, you will provide the candidate
* edges that a user is trying to create here, or the initial set of edges
* you know about.
*
* @param dict $nodes A map of nodes to the nodes reachable along their
* edges
- * @return this
+ * @return $this
* @task build
*/
final public function addNodes(array $nodes) {
if ($this->graphLoaded) {
throw new Exception(
pht(
'Call %s before calling %s. You can not add more nodes '.
'once you have loaded the graph.',
__FUNCTION__.'()',
'loadGraph()'));
}
$this->knownNodes += $nodes;
return $this;
}
final public function getNodes() {
return $this->knownNodes;
}
/**
* Get the nodes in topological order.
*
* This method requires the graph be acyclic. For graphs which may contain
* cycles, see @{method:getNodesInRoughTopologicalOrder}.
*/
final public function getNodesInTopologicalOrder() {
$sorted = array();
$nodes = $this->getNodes();
$inverse_map = array();
foreach ($nodes as $node => $edges) {
if (!isset($inverse_map[$node])) {
$inverse_map[$node] = array();
}
foreach ($edges as $edge) {
if (!isset($inverse_map[$edge])) {
$inverse_map[$edge] = array();
}
$inverse_map[$edge][$node] = $node;
}
}
$end_nodes = array();
foreach ($inverse_map as $node => $edges) {
if (empty($edges)) {
$end_nodes[] = $node;
}
}
while (!empty($end_nodes)) {
$current_node = array_pop($end_nodes);
$sorted[] = $current_node;
$current_edges = $nodes[$current_node];
foreach ($current_edges as $index => $current_edge) {
// delete the edge from the normal map
unset($nodes[$current_node][$index]);
// and from the inverse map which is modestly trickier
$inverse_nodes = $inverse_map[$current_edge];
unset($inverse_nodes[$current_node]);
$inverse_map[$current_edge] = $inverse_nodes;
// no more edges means this is an "end node" now
if (empty($inverse_map[$current_edge])) {
$end_nodes[] = $current_edge;
}
}
}
return $sorted;
}
/**
* Get the nodes in topological order, or some roughly similar order if
* the graph contains cycles.
*
* This method will return an ordering for cyclic graphs. The method will
* attempt to make it look like a topological ordering, but since cyclic
* graphs have no complete toplogical ordering, you might get anything.
*
* If you know the graph is acyclic and want an actual topological order,
* use @{method:getNodesInTopologicalOrder}.
*/
final public function getNodesInRoughTopologicalOrder() {
$nodes = $this->getNodes();
$edges = $this->loadEdges($nodes);
$results = array();
$completed = array();
$depth = 0;
while (true) {
$next = array();
foreach ($nodes as $node) {
if (isset($completed[$node])) {
continue;
}
$capable = true;
foreach ($edges[$node] as $edge) {
if (!isset($completed[$edge])) {
$capable = false;
break;
}
}
if ($capable) {
$next[] = $node;
}
}
if (count($next) === 0) {
// No more nodes to traverse; we are deadlocked if the number
// of completed nodes is less than the total number of nodes.
break;
}
foreach ($next as $node) {
$results[] = array(
'node' => $node,
'depth' => $depth,
'cycle' => false,
);
$completed[$node] = true;
}
$depth++;
}
foreach ($nodes as $node) {
if (!isset($completed[$node])) {
$results[] = array(
'node' => $node,
'depth' => $depth,
'cycle' => true,
);
}
}
return $results;
}
/**
* Load the graph, building it out so operations can be performed on it. This
* constructs the graph level-by-level, calling @{method:loadEdges} to
* expand the graph at each stage until it is complete.
*
- * @return this
+ * @return $this
* @task build
*/
final public function loadGraph() {
$new_nodes = $this->knownNodes;
while (true) {
$load = array();
foreach ($new_nodes as $node => $edges) {
foreach ($edges as $edge) {
if (!isset($this->knownNodes[$edge])) {
$load[$edge] = true;
}
}
}
if (empty($load)) {
break;
}
$load = array_keys($load);
$new_nodes = $this->loadEdges($load);
foreach ($load as $node) {
if (!isset($new_nodes[$node]) || !is_array($new_nodes[$node])) {
throw new Exception(
pht(
'%s must return an edge list array for each provided '.
'node, or the cycle detection algorithm may not terminate.',
'loadEdges()'));
}
}
$this->addNodes($new_nodes);
}
$this->graphLoaded = true;
return $this;
}
/* -( Cycle Detection )---------------------------------------------------- */
/**
* Detect if there are any cycles reachable from a given node.
*
* If cycles are reachable, it returns a list of nodes which create a cycle.
* Note that this list may include nodes which aren't actually part of the
* cycle, but lie on the graph between the specified node and the cycle.
* For example, it might return something like this (when passed "A"):
*
* A, B, C, D, E, C
*
* This means you can walk from A to B to C to D to E and then back to C,
* which forms a cycle. A and B are included even though they are not part
* of the cycle. When presenting information about graph cycles to users,
* including these nodes is generally useful. This also shouldn't ever happen
* if you've vetted prior edges before writing them, because it means there
* is a preexisting cycle in the graph.
*
* NOTE: This only detects cycles reachable from a node. It does not detect
* cycles in the entire graph.
*
* @param scalar $node The node to walk from, looking for graph cycles.
* @return list|null Returns null if no cycles are reachable from the node,
* or a list of nodes that form a cycle.
* @task cycle
*/
final public function detectCycles($node) {
if (!$this->graphLoaded) {
throw new Exception(
pht(
'Call %s to build the graph out before calling %s.',
'loadGraph()',
__FUNCTION__.'()'));
}
if (!isset($this->knownNodes[$node])) {
throw new Exception(
pht(
"The node '%s' is not known. Call %s to seed the graph with nodes.",
$node,
'addNodes()'));
}
$visited = array();
return $this->performCycleDetection($node, $visited);
}
/**
* Internal cycle detection implementation. Recursively walks the graph,
* keeping track of where it's been, and returns the first cycle it finds.
*
* @param scalar $node The node to walk from.
* @param list $visited Previously visited nodes.
* @return null|list Null if no cycles are found, or a list of nodes
* which cycle.
* @task cycle
*/
private function performCycleDetection($node, array $visited) {
$visited[$node] = true;
foreach ($this->knownNodes[$node] as $edge) {
if (isset($visited[$edge])) {
$result = array_keys($visited);
$result[] = $edge;
return $result;
}
$result = $this->performCycleDetection($edge, $visited);
if ($result) {
return $result;
}
}
return null;
}
}
diff --git a/src/utils/PhutilBufferedIterator.php b/src/utils/PhutilBufferedIterator.php
index 598629f7..f15b7d75 100644
--- a/src/utils/PhutilBufferedIterator.php
+++ b/src/utils/PhutilBufferedIterator.php
@@ -1,138 +1,138 @@
<?php
/**
* Simple iterator that loads results page-by-page and handles buffering. In
* particular, this maps well to iterators that load database results page
* by page and allows you to implement an iterator over a large result set
* without needing to hold the entire set in memory.
*
* For an example implementation, see @{class:PhutilExampleBufferedIterator}.
*
* @task impl Methods to Implement
* @task config Configuration
* @task iterator Iterator Implementation
*/
abstract class PhutilBufferedIterator extends Phobject implements Iterator {
private $data;
private $pageSize = 100;
private $naturalKey;
/* -( Methods to Implement )----------------------------------------------- */
/**
* Called when @{method:rewind} is invoked. You should reset any internal
* cursor your implementation holds.
*
* @return void
* @task impl
*/
abstract protected function didRewind();
/**
* Called when the iterator needs a page of results. You should load the next
* result page and update your internal cursor to point past it.
*
* If possible, you should use @{method:getPageSize} to choose a page size.
*
* @return list<wild> List of results.
* @task impl
*/
abstract protected function loadPage();
/* -( Configuration )------------------------------------------------------ */
/**
* Get the configured page size.
*
* @return int Page size.
* @task config
*/
final public function getPageSize() {
return $this->pageSize;
}
/**
* Configure the page size. Note that implementations may ignore this.
*
* @param int $size Page size.
- * @return this
+ * @return $this
* @task config
*/
final public function setPageSize($size) {
$this->pageSize = $size;
return $this;
}
/* -( Iterator Implementation )-------------------------------------------- */
/**
* @task iterator
*/
final public function rewind() {
$this->didRewind();
$this->data = array();
$this->naturalKey = 0;
$this->next();
}
/**
* @task iterator
*/
final public function valid() {
return (bool)count($this->data);
}
/**
* @task iterator
*/
final public function current() {
return end($this->data);
}
/**
* By default, the iterator assigns a "natural" key (0, 1, 2, ...) to each
* result. This method is intentionally nonfinal so you can substitute a
* different behavior by overriding it if you prefer.
*
* @return scalar Key for the current result (as per @{method:current}).
* @task iterator
*/
public function key() {
return $this->naturalKey;
}
/**
* @task iterator
*/
final public function next() {
if ($this->data) {
$this->naturalKey++;
array_pop($this->data);
if ($this->data) {
return;
}
}
$data = $this->loadPage();
// NOTE: Reverse the results so we can use array_pop() to discard them,
// since it doesn't have the O(N) key reassignment behavior of
// array_shift().
$this->data = array_reverse($data);
}
}
diff --git a/src/utils/PhutilRope.php b/src/utils/PhutilRope.php
index bb0ddb7f..36eae9ba 100644
--- a/src/utils/PhutilRope.php
+++ b/src/utils/PhutilRope.php
@@ -1,144 +1,144 @@
<?php
/**
* String-like object which reduces the cost of managing large strings. This
* is particularly useful for buffering large amounts of data that is being
* passed to `fwrite()`.
*/
final class PhutilRope extends Phobject {
private $length = 0;
private $buffers = array();
// This is is arbitrary, it's just the maximum size I'm reliably able to
// fwrite() to a pipe on OSX. In theory, we could tune this slightly based
// on the pipe buffer size, but any value similar to this shouldn't affect
// performance much.
private $segmentSize = 16384;
/**
* Append a string to the rope.
*
* @param string $string String to append.
- * @return this
+ * @return $this
*/
public function append($string) {
if (!strlen($string)) {
return $this;
}
$len = strlen($string);
$this->length += $len;
if ($len <= $this->segmentSize) {
$this->buffers[] = $string;
} else {
for ($cursor = 0; $cursor < $len; $cursor += $this->segmentSize) {
$this->buffers[] = substr($string, $cursor, $this->segmentSize);
}
}
return $this;
}
/**
* Get the length of the rope.
*
* @return int Length of the rope in bytes.
*/
public function getByteLength() {
return $this->length;
}
/**
* Get an arbitrary, nonempty prefix of the rope.
*
* @return string|null Some rope prefix.
*/
public function getAnyPrefix() {
$result = reset($this->buffers);
if ($result === false) {
return null;
}
return $result;
}
/**
* Get prefix bytes of the rope, up to some maximum size.
*
* @param int $length Maximum number of bytes to read.
* @return string Bytes.
*/
public function getPrefixBytes($length) {
$result = array();
$remaining_bytes = $length;
foreach ($this->buffers as $buf) {
$length = strlen($buf);
if ($length <= $remaining_bytes) {
$result[] = $buf;
$remaining_bytes -= $length;
} else {
$result[] = substr($buf, 0, $remaining_bytes);
$remaining_bytes = 0;
}
if (!$remaining_bytes) {
break;
}
}
return implode('', $result);
}
/**
* Return the entire rope as a normal string.
*
* @return string Normal string.
*/
public function getAsString() {
return implode('', $this->buffers);
}
/**
* Remove a specified number of bytes from the head of the rope.
*
* @param int $remove Bytes to remove.
- * @return this
+ * @return $this
*/
public function removeBytesFromHead($remove) {
if ($remove <= 0) {
throw new InvalidArgumentException(
pht('Length must be larger than 0!'));
}
$remaining_bytes = $remove;
foreach ($this->buffers as $key => $buf) {
$len = strlen($buf);
if ($len <= $remaining_bytes) {
unset($this->buffers[$key]);
$remaining_bytes -= $len;
if (!$remaining_bytes) {
break;
}
} else {
$this->buffers[$key] = substr($buf, $remaining_bytes);
break;
}
}
if ($this->buffers) {
$this->length -= $remove;
} else {
$this->length = 0;
}
return $this;
}
}
diff --git a/src/workflow/ArcanistWorkflow.php b/src/workflow/ArcanistWorkflow.php
index 9522812b..7efc006e 100644
--- a/src/workflow/ArcanistWorkflow.php
+++ b/src/workflow/ArcanistWorkflow.php
@@ -1,2477 +1,2477 @@
<?php
/**
* Implements a runnable command, like "arc diff" or "arc help".
*
* = Managing Conduit =
*
* Workflows have the builtin ability to open a Conduit connection to a
* Phabricator installation, so methods can be invoked over the API. Workflows
* may either not need this (e.g., "help"), or may need a Conduit but not
* authentication (e.g., calling only public APIs), or may need a Conduit and
* authentication (e.g., "arc diff").
*
* To specify that you need an //unauthenticated// conduit, override
* @{method:requiresConduit} to return ##true##. To specify that you need an
* //authenticated// conduit, override @{method:requiresAuthentication} to
* return ##true##. You can also manually invoke @{method:establishConduit}
* and/or @{method:authenticateConduit} later in a workflow to upgrade it.
* Once a conduit is open, you can access the client by calling
* @{method:getConduit}, which allows you to invoke methods. You can get
* verified information about the user identity by calling @{method:getUserPHID}
* or @{method:getUserName} after authentication occurs.
*
* = Scratch Files =
*
* Arcanist workflows can read and write 'scratch files', which are temporary
* files stored in the project that persist across commands. They can be useful
* if you want to save some state, or keep a copy of a long message the user
* entered if something goes wrong.
*
*
* @task conduit Conduit
* @task scratch Scratch Files
* @task phabrep Phabricator Repositories
*/
abstract class ArcanistWorkflow extends Phobject {
const COMMIT_DISABLE = 0;
const COMMIT_ALLOW = 1;
const COMMIT_ENABLE = 2;
private $commitMode = self::COMMIT_DISABLE;
private $conduit;
private $conduitURI;
private $conduitCredentials;
private $conduitAuthenticated;
private $conduitTimeout;
private $userPHID;
private $userName;
private $repositoryAPI;
private $configurationManager;
private $arguments = array();
private $command;
private $stashed;
private $shouldAmend;
private $projectInfo;
private $repositoryInfo;
private $repositoryReasons;
private $repositoryRef;
private $arcanistConfiguration;
private $parentWorkflow;
private $workingDirectory;
private $repositoryVersion;
private $changeCache = array();
private $conduitEngine;
private $toolset;
private $runtime;
private $configurationEngine;
private $configurationSourceList;
private $promptMap;
final public function setToolset(ArcanistToolset $toolset) {
$this->toolset = $toolset;
return $this;
}
final public function getToolset() {
return $this->toolset;
}
final public function setRuntime(ArcanistRuntime $runtime) {
$this->runtime = $runtime;
return $this;
}
final public function getRuntime() {
return $this->runtime;
}
final public function setConfigurationEngine(
ArcanistConfigurationEngine $engine) {
$this->configurationEngine = $engine;
return $this;
}
final public function getConfigurationEngine() {
return $this->configurationEngine;
}
final public function setConfigurationSourceList(
ArcanistConfigurationSourceList $list) {
$this->configurationSourceList = $list;
return $this;
}
final public function getConfigurationSourceList() {
return $this->configurationSourceList;
}
public function newPhutilWorkflow() {
$arguments = $this->getWorkflowArguments();
assert_instances_of($arguments, 'ArcanistWorkflowArgument');
$specs = mpull($arguments, 'getPhutilSpecification');
$phutil_workflow = id(new ArcanistPhutilWorkflow())
->setName($this->getWorkflowName())
->setWorkflow($this)
->setArguments($specs);
$information = $this->getWorkflowInformation();
if ($information !== null) {
if (!($information instanceof ArcanistWorkflowInformation)) {
throw new Exception(
pht(
'Expected workflow ("%s", of class "%s") to return an '.
'"ArcanistWorkflowInformation" object from call to '.
'"getWorkflowInformation()", got %s.',
$this->getWorkflowName(),
get_class($this),
phutil_describe_type($information)));
}
}
if ($information) {
$synopsis = $information->getSynopsis();
if ($synopsis !== null) {
$phutil_workflow->setSynopsis($synopsis);
}
$examples = $information->getExamples();
if ($examples) {
$examples = implode("\n", $examples);
$phutil_workflow->setExamples($examples);
}
$help = $information->getHelp();
if ($help !== null) {
// Unwrap linebreaks in the help text so we don't get weird formatting.
$help = preg_replace("/(?<=\S)\n(?=\S)/", ' ', $help);
$phutil_workflow->setHelp($help);
}
}
return $phutil_workflow;
}
final public function newLegacyPhutilWorkflow() {
$phutil_workflow = id(new ArcanistPhutilWorkflow())
->setName($this->getWorkflowName());
$arguments = $this->getArguments();
$specs = array();
foreach ($arguments as $key => $argument) {
if ($key == '*') {
$key = $argument;
$argument = array(
'wildcard' => true,
);
}
unset($argument['paramtype']);
unset($argument['supports']);
unset($argument['nosupport']);
unset($argument['passthru']);
unset($argument['conflict']);
$spec = array(
'name' => $key,
) + $argument;
$specs[] = $spec;
}
$phutil_workflow->setArguments($specs);
$synopses = $this->getCommandSynopses();
$phutil_workflow->setSynopsis($synopses);
$help = $this->getCommandHelp();
if (strlen($help)) {
$phutil_workflow->setHelp($help);
}
return $phutil_workflow;
}
final protected function newWorkflowArgument($key) {
return id(new ArcanistWorkflowArgument())
->setKey($key);
}
final protected function newWorkflowInformation() {
return new ArcanistWorkflowInformation();
}
final public function executeWorkflow(PhutilArgumentParser $args) {
$runtime = $this->getRuntime();
$this->arguments = $args;
$caught = null;
$runtime->pushWorkflow($this);
try {
$err = $this->runWorkflow($args);
} catch (Exception $ex) {
$caught = $ex;
}
try {
$this->runWorkflowCleanup();
} catch (Exception $ex) {
phlog($ex);
}
$runtime->popWorkflow();
if ($caught) {
throw $caught;
}
return $err;
}
final public function getLogEngine() {
return $this->getRuntime()->getLogEngine();
}
protected function runWorkflowCleanup() {
// TOOLSETS: Do we need this?
return;
}
public function __construct() {}
public function run() {
throw new PhutilMethodNotImplementedException();
}
/**
* Finalizes any cleanup operations that need to occur regardless of
* whether the command succeeded or failed.
*/
public function finalize() {
$this->finalizeWorkingCopy();
}
/**
* Return the command used to invoke this workflow from the command like,
* e.g. "help" for @{class:ArcanistHelpWorkflow}.
*
* @return string The command a user types to invoke this workflow.
*/
abstract public function getWorkflowName();
/**
* Return console formatted string with all command synopses.
*
* @return string 6-space indented list of available command synopses.
*/
public function getCommandSynopses() {
return array();
}
/**
* Return console formatted string with command help printed in `arc help`.
*
* @return string 10-space indented help to use the command.
*/
public function getCommandHelp() {
return null;
}
public function supportsToolset(ArcanistToolset $toolset) {
return false;
}
/* -( Conduit )------------------------------------------------------------ */
/**
* Set the URI which the workflow will open a conduit connection to when
* @{method:establishConduit} is called. Arcanist makes an effort to set
* this by default for all workflows (by reading ##.arcconfig## and/or the
* value of ##--conduit-uri##) even if they don't need Conduit, so a workflow
* can generally upgrade into a conduit workflow later by just calling
* @{method:establishConduit}.
*
* You generally should not need to call this method unless you are
* specifically overriding the default URI. It is normally sufficient to
* just invoke @{method:establishConduit}.
*
* NOTE: You can not call this after a conduit has been established.
*
* @param string $conduit_uri The URI to open a conduit to when
* @{method:establishConduit} is called.
- * @return this
+ * @return $this
* @task conduit
*/
final public function setConduitURI($conduit_uri) {
if ($this->conduit) {
throw new Exception(
pht(
'You can not change the Conduit URI after a '.
'conduit is already open.'));
}
$this->conduitURI = $conduit_uri;
return $this;
}
/**
* Returns the URI the conduit connection within the workflow uses.
*
* @return string
* @task conduit
*/
final public function getConduitURI() {
return $this->conduitURI;
}
/**
* Open a conduit channel to the server which was previously configured by
* calling @{method:setConduitURI}. Arcanist will do this automatically if
* the workflow returns ##true## from @{method:requiresConduit}, or you can
* later upgrade a workflow and build a conduit by invoking it manually.
*
* You must establish a conduit before you can make conduit calls.
*
* NOTE: You must call @{method:setConduitURI} before you can call this
* method.
*
- * @return this
+ * @return $this
* @task conduit
*/
final public function establishConduit() {
if ($this->conduit) {
return $this;
}
if (!$this->conduitURI) {
throw new Exception(
pht(
'You must specify a Conduit URI with %s before you can '.
'establish a conduit.',
'setConduitURI()'));
}
$this->conduit = new ConduitClient($this->conduitURI);
if ($this->conduitTimeout) {
$this->conduit->setTimeout($this->conduitTimeout);
}
return $this;
}
final public function getConfigFromAnySource($key) {
$source_list = $this->getConfigurationSourceList();
if ($source_list) {
$value_list = $source_list->getStorageValueList($key);
if ($value_list) {
return last($value_list)->getValue();
}
return null;
}
return $this->configurationManager->getConfigFromAnySource($key);
}
/**
* Set credentials which will be used to authenticate against Conduit. These
* credentials can then be used to establish an authenticated connection to
* conduit by calling @{method:authenticateConduit}. Arcanist sets some
* defaults for all workflows regardless of whether or not they return true
* from @{method:requireAuthentication}, based on the ##~/.arcrc## and
* ##.arcconf## files if they are present. Thus, you can generally upgrade a
* workflow which does not require authentication into an authenticated
* workflow by later invoking @{method:requireAuthentication}. You should not
* normally need to call this method unless you are specifically overriding
* the defaults.
*
* NOTE: You can not call this method after calling
* @{method:authenticateConduit}.
*
* @param dict $credentials A credential dictionary, see
* @{method:authenticateConduit}.
- * @return this
+ * @return $this
* @task conduit
*/
final public function setConduitCredentials(array $credentials) {
if ($this->isConduitAuthenticated()) {
throw new Exception(
pht('You may not set new credentials after authenticating conduit.'));
}
$this->conduitCredentials = $credentials;
return $this;
}
/**
* Get the protocol version the client should identify with.
*
* @return int Version the client should claim to be.
* @task conduit
*/
final public function getConduitVersion() {
return 6;
}
/**
* Open and authenticate a conduit connection to a Phabricator server using
* provided credentials. Normally, Arcanist does this for you automatically
* when you return true from @{method:requiresAuthentication}, but you can
* also upgrade an existing workflow to one with an authenticated conduit
* by invoking this method manually.
*
* You must authenticate the conduit before you can make authenticated conduit
* calls (almost all calls require authentication).
*
* This method uses credentials provided via @{method:setConduitCredentials}
* to authenticate to the server:
*
* - ##user## (required) The username to authenticate with.
* - ##certificate## (required) The Conduit certificate to use.
* - ##description## (optional) Description of the invoking command.
*
* Successful authentication allows you to call @{method:getUserPHID} and
* @{method:getUserName}, as well as use the client you access with
* @{method:getConduit} to make authenticated calls.
*
* NOTE: You must call @{method:setConduitURI} and
* @{method:setConduitCredentials} before you invoke this method.
*
- * @return this
+ * @return $this
* @task conduit
*/
final public function authenticateConduit() {
if ($this->isConduitAuthenticated()) {
return $this;
}
$this->establishConduit();
$credentials = $this->conduitCredentials;
try {
if (!$credentials) {
throw new Exception(
pht(
'Set conduit credentials with %s before authenticating conduit!',
'setConduitCredentials()'));
}
// If we have `token`, this server supports the simpler, new-style
// token-based authentication. Use that instead of all the certificate
// stuff.
$token = idx($credentials, 'token');
if (phutil_nonempty_string($token)) {
$conduit = $this->getConduit();
$conduit->setConduitToken($token);
try {
$result = $this->getConduit()->callMethodSynchronous(
'user.whoami',
array());
$this->userName = $result['userName'];
$this->userPHID = $result['phid'];
$this->conduitAuthenticated = true;
return $this;
} catch (Exception $ex) {
$conduit->setConduitToken(null);
throw $ex;
}
}
if (empty($credentials['user'])) {
throw new ConduitClientException(
'ERR-INVALID-USER',
pht('Empty user in credentials.'));
}
if (empty($credentials['certificate'])) {
throw new ConduitClientException(
'ERR-NO-CERTIFICATE',
pht('Empty certificate in credentials.'));
}
$description = idx($credentials, 'description', '');
$user = $credentials['user'];
$certificate = $credentials['certificate'];
$connection = $this->getConduit()->callMethodSynchronous(
'conduit.connect',
array(
'client' => 'arc',
'clientVersion' => $this->getConduitVersion(),
'clientDescription' => php_uname('n').':'.$description,
'user' => $user,
'certificate' => $certificate,
'host' => $this->conduitURI,
));
} catch (ConduitClientException $ex) {
if ($ex->getErrorCode() == 'ERR-NO-CERTIFICATE' ||
$ex->getErrorCode() == 'ERR-INVALID-USER' ||
$ex->getErrorCode() == 'ERR-INVALID-AUTH') {
$conduit_uri = $this->conduitURI;
$message = phutil_console_format(
"\n%s\n\n %s\n\n%s\n%s",
pht('YOU NEED TO __INSTALL A CERTIFICATE__ TO LOG IN'),
pht('To do this, run: **%s**', 'arc install-certificate'),
pht("The server '%s' rejected your request:", $conduit_uri),
$ex->getMessage());
throw new ArcanistUsageException($message);
} else if ($ex->getErrorCode() == 'NEW-ARC-VERSION') {
// Cleverly disguise this as being AWESOME!!!
echo phutil_console_format("**%s**\n\n", pht('New Version Available!'));
echo phutil_console_wrap($ex->getMessage());
echo "\n\n";
echo pht('In most cases, arc can be upgraded automatically.')."\n";
$ok = phutil_console_confirm(
pht('Upgrade arc now?'),
$default_no = false);
if (!$ok) {
throw $ex;
}
$root = dirname(phutil_get_library_root('arcanist'));
chdir($root);
$err = phutil_passthru('%s upgrade', $root.'/bin/arc');
if (!$err) {
echo "\n".pht('Try running your arc command again.')."\n";
}
exit(1);
} else {
throw $ex;
}
}
$this->userName = $user;
$this->userPHID = $connection['userPHID'];
$this->conduitAuthenticated = true;
return $this;
}
/**
* @return bool True if conduit is authenticated, false otherwise.
* @task conduit
*/
final protected function isConduitAuthenticated() {
return (bool)$this->conduitAuthenticated;
}
/**
* Override this to return true if your workflow requires a conduit channel.
* Arc will build the channel for you before your workflow executes. This
* implies that you only need an unauthenticated channel; if you need
* authentication, override @{method:requiresAuthentication}.
*
* @return bool True if arc should build a conduit channel before running
* the workflow.
* @task conduit
*/
public function requiresConduit() {
return false;
}
/**
* Override this to return true if your workflow requires an authenticated
* conduit channel. This implies that it requires a conduit. Arc will build
* and authenticate the channel for you before the workflow executes.
*
* @return bool True if arc should build an authenticated conduit channel
* before running the workflow.
* @task conduit
*/
public function requiresAuthentication() {
return false;
}
/**
* Returns the PHID for the user once they've authenticated via Conduit.
*
* @return phid Authenticated user PHID.
* @task conduit
*/
final public function getUserPHID() {
if (!$this->userPHID) {
$workflow = get_class($this);
throw new Exception(
pht(
"This workflow ('%s') requires authentication, override ".
"%s to return true.",
$workflow,
'requiresAuthentication()'));
}
return $this->userPHID;
}
/**
* Return the username for the user once they've authenticated via Conduit.
*
* @return string Authenticated username.
* @task conduit
*/
final public function getUserName() {
return $this->userName;
}
/**
* Get the established @{class@libphutil:ConduitClient} in order to make
* Conduit method calls. Before the client is available it must be connected,
* either implicitly by making @{method:requireConduit} or
* @{method:requireAuthentication} return true, or explicitly by calling
* @{method:establishConduit} or @{method:authenticateConduit}.
*
* @return @{class@libphutil:ConduitClient} Live conduit client.
* @task conduit
*/
final public function getConduit() {
if (!$this->conduit) {
$workflow = get_class($this);
throw new Exception(
pht(
"This workflow ('%s') requires a Conduit, override ".
"%s to return true.",
$workflow,
'requiresConduit()'));
}
return $this->conduit;
}
final public function setArcanistConfiguration(
ArcanistConfiguration $arcanist_configuration) {
$this->arcanistConfiguration = $arcanist_configuration;
return $this;
}
final public function getArcanistConfiguration() {
return $this->arcanistConfiguration;
}
final public function setConfigurationManager(
ArcanistConfigurationManager $arcanist_configuration_manager) {
$this->configurationManager = $arcanist_configuration_manager;
return $this;
}
final public function getConfigurationManager() {
return $this->configurationManager;
}
public function requiresWorkingCopy() {
return false;
}
public function desiresWorkingCopy() {
return false;
}
public function requiresRepositoryAPI() {
return false;
}
public function desiresRepositoryAPI() {
return false;
}
final public function setCommand($command) {
$this->command = $command;
return $this;
}
final public function getCommand() {
return $this->command;
}
public function getArguments() {
return array();
}
final public function setWorkingDirectory($working_directory) {
$this->workingDirectory = $working_directory;
return $this;
}
final public function getWorkingDirectory() {
return $this->workingDirectory;
}
private function setParentWorkflow($parent_workflow) {
$this->parentWorkflow = $parent_workflow;
return $this;
}
final protected function getParentWorkflow() {
return $this->parentWorkflow;
}
final public function buildChildWorkflow($command, array $argv) {
$arc_config = $this->getArcanistConfiguration();
$workflow = $arc_config->buildWorkflow($command);
$workflow->setParentWorkflow($this);
$workflow->setConduitEngine($this->getConduitEngine());
$workflow->setCommand($command);
$workflow->setConfigurationManager($this->getConfigurationManager());
if ($this->repositoryAPI) {
$workflow->setRepositoryAPI($this->repositoryAPI);
}
if ($this->userPHID) {
$workflow->userPHID = $this->getUserPHID();
$workflow->userName = $this->getUserName();
}
if ($this->conduit) {
$workflow->conduit = $this->conduit;
$workflow->setConduitCredentials($this->conduitCredentials);
$workflow->conduitAuthenticated = $this->conduitAuthenticated;
}
$workflow->setArcanistConfiguration($arc_config);
$workflow->parseArguments(array_values($argv));
return $workflow;
}
final public function getArgument($key, $default = null) {
// TOOLSETS: Remove this legacy code.
if (is_array($this->arguments)) {
return idx($this->arguments, $key, $default);
}
return $this->arguments->getArg($key);
}
final public function getCompleteArgumentSpecification() {
$spec = $this->getArguments();
$arc_config = $this->getArcanistConfiguration();
$command = $this->getCommand();
$spec += $arc_config->getCustomArgumentsForCommand($command);
return $spec;
}
final public function parseArguments(array $args) {
$spec = $this->getCompleteArgumentSpecification();
$dict = array();
$more_key = null;
if (!empty($spec['*'])) {
$more_key = $spec['*'];
unset($spec['*']);
$dict[$more_key] = array();
}
$short_to_long_map = array();
foreach ($spec as $long => $options) {
if (!empty($options['short'])) {
$short_to_long_map[$options['short']] = $long;
}
}
foreach ($spec as $long => $options) {
if (!empty($options['repeat'])) {
$dict[$long] = array();
}
}
$more = array();
$size = count($args);
for ($ii = 0; $ii < $size; $ii++) {
$arg = $args[$ii];
$arg_name = null;
$arg_key = null;
if ($arg == '--') {
$more = array_merge(
$more,
array_slice($args, $ii + 1));
break;
} else if (!strncmp($arg, '--', 2)) {
$arg_key = substr($arg, 2);
$parts = explode('=', $arg_key, 2);
if (count($parts) == 2) {
list($arg_key, $val) = $parts;
array_splice($args, $ii, 1, array('--'.$arg_key, $val));
$size++;
}
if (!array_key_exists($arg_key, $spec)) {
$corrected = PhutilArgumentSpellingCorrector::newFlagCorrector()
->correctSpelling($arg_key, array_keys($spec));
if (count($corrected) == 1) {
PhutilConsole::getConsole()->writeErr(
pht(
"(Assuming '%s' is the British spelling of '%s'.)",
'--'.$arg_key,
'--'.head($corrected))."\n");
$arg_key = head($corrected);
} else {
throw new ArcanistUsageException(
pht(
"Unknown argument '%s'. Try '%s'.",
$arg_key,
'arc help'));
}
}
} else if (!strncmp($arg, '-', 1)) {
$arg_key = substr($arg, 1);
if (empty($short_to_long_map[$arg_key])) {
throw new ArcanistUsageException(
pht(
"Unknown argument '%s'. Try '%s'.",
$arg_key,
'arc help'));
}
$arg_key = $short_to_long_map[$arg_key];
} else {
$more[] = $arg;
continue;
}
$options = $spec[$arg_key];
if (empty($options['param'])) {
$dict[$arg_key] = true;
} else {
if ($ii == $size - 1) {
throw new ArcanistUsageException(
pht(
"Option '%s' requires a parameter.",
$arg));
}
if (!empty($options['repeat'])) {
$dict[$arg_key][] = $args[$ii + 1];
} else {
$dict[$arg_key] = $args[$ii + 1];
}
$ii++;
}
}
if ($more) {
if ($more_key) {
$dict[$more_key] = $more;
} else {
$example = reset($more);
throw new ArcanistUsageException(
pht(
"Unrecognized argument '%s'. Try '%s'.",
$example,
'arc help'));
}
}
foreach ($dict as $key => $value) {
if (empty($spec[$key]['conflicts'])) {
continue;
}
foreach ($spec[$key]['conflicts'] as $conflict => $more) {
if (isset($dict[$conflict])) {
if ($more) {
$more = ': '.$more;
} else {
$more = '.';
}
// TODO: We'll always display these as long-form, when the user might
// have typed them as short form.
throw new ArcanistUsageException(
pht(
"Arguments '%s' and '%s' are mutually exclusive",
"--{$key}",
"--{$conflict}").$more);
}
}
}
$this->arguments = $dict;
$this->didParseArguments();
return $this;
}
protected function didParseArguments() {
// Override this to customize workflow argument behavior.
}
final public function getWorkingCopy() {
$configuration_engine = $this->getConfigurationEngine();
// TOOLSETS: Remove this once all workflows are toolset workflows.
if (!$configuration_engine) {
throw new Exception(
pht(
'This workflow has not yet been updated to Toolsets and can '.
'not retrieve a modern WorkingCopy object. Use '.
'"getWorkingCopyIdentity()" to retrieve a previous-generation '.
'object.'));
}
return $configuration_engine->getWorkingCopy();
}
final public function getWorkingCopyIdentity() {
$configuration_engine = $this->getConfigurationEngine();
if ($configuration_engine) {
$working_copy = $configuration_engine->getWorkingCopy();
$working_path = $working_copy->getWorkingDirectory();
return ArcanistWorkingCopyIdentity::newFromPath($working_path);
}
$working_copy = $this->getConfigurationManager()->getWorkingCopyIdentity();
if (!$working_copy) {
$workflow = get_class($this);
throw new Exception(
pht(
"This workflow ('%s') requires a working copy, override ".
"%s to return true.",
$workflow,
'requiresWorkingCopy()'));
}
return $working_copy;
}
final public function setRepositoryAPI($api) {
$this->repositoryAPI = $api;
return $this;
}
final public function hasRepositoryAPI() {
try {
return (bool)$this->getRepositoryAPI();
} catch (Exception $ex) {
return false;
}
}
final public function getRepositoryAPI() {
$configuration_engine = $this->getConfigurationEngine();
if ($configuration_engine) {
$working_copy = $configuration_engine->getWorkingCopy();
return $working_copy->getRepositoryAPI();
}
if (!$this->repositoryAPI) {
$workflow = get_class($this);
throw new Exception(
pht(
"This workflow ('%s') requires a Repository API, override ".
"%s to return true.",
$workflow,
'requiresRepositoryAPI()'));
}
return $this->repositoryAPI;
}
final protected function shouldRequireCleanUntrackedFiles() {
return empty($this->arguments['allow-untracked']);
}
final public function setCommitMode($mode) {
$this->commitMode = $mode;
return $this;
}
final public function finalizeWorkingCopy() {
if ($this->stashed) {
$api = $this->getRepositoryAPI();
$api->unstashChanges();
echo pht('Restored stashed changes to the working directory.')."\n";
}
}
final public function requireCleanWorkingCopy() {
$api = $this->getRepositoryAPI();
$must_commit = array();
$working_copy_desc = phutil_console_format(
" %s: __%s__\n\n",
pht('Working copy'),
$api->getPath());
// NOTE: this is a subversion-only concept.
$incomplete = $api->getIncompleteChanges();
if ($incomplete) {
throw new ArcanistUsageException(
sprintf(
"%s\n\n%s %s\n %s\n\n%s",
pht(
"You have incompletely checked out directories in this working ".
"copy. Fix them before proceeding.'"),
$working_copy_desc,
pht('Incomplete directories in working copy:'),
implode("\n ", $incomplete),
pht(
"You can fix these paths by running '%s' on them.",
'svn update')));
}
$conflicts = $api->getMergeConflicts();
if ($conflicts) {
throw new ArcanistUsageException(
sprintf(
"%s\n\n%s %s\n %s",
pht(
'You have merge conflicts in this working copy. Resolve merge '.
'conflicts before proceeding.'),
$working_copy_desc,
pht('Conflicts in working copy:'),
implode("\n ", $conflicts)));
}
$missing = $api->getMissingChanges();
if ($missing) {
throw new ArcanistUsageException(
sprintf(
"%s\n\n%s %s\n %s\n",
pht(
'You have missing files in this working copy. Revert or formally '.
'remove them (with `%s`) before proceeding.',
'svn rm'),
$working_copy_desc,
pht('Missing files in working copy:'),
implode("\n ", $missing)));
}
$externals = $api->getDirtyExternalChanges();
// TODO: This state can exist in Subversion, but it is currently handled
// elsewhere. It should probably be handled here, eventually.
if ($api instanceof ArcanistSubversionAPI) {
$externals = array();
}
if ($externals) {
$message = pht(
'%s submodule(s) have uncommitted or untracked changes:',
new PhutilNumber(count($externals)));
$prompt = pht(
'Ignore the changes to these %s submodule(s) and continue?',
new PhutilNumber(count($externals)));
$list = id(new PhutilConsoleList())
->setWrap(false)
->addItems($externals);
id(new PhutilConsoleBlock())
->addParagraph($message)
->addList($list)
->draw();
$ok = phutil_console_confirm($prompt, $default_no = false);
if (!$ok) {
throw new ArcanistUserAbortException();
}
}
$uncommitted = $api->getUncommittedChanges();
$unstaged = $api->getUnstagedChanges();
// We already dealt with externals.
$unstaged = array_diff($unstaged, $externals);
// We only want files which are purely uncommitted.
$uncommitted = array_diff($uncommitted, $unstaged);
$uncommitted = array_diff($uncommitted, $externals);
$untracked = $api->getUntrackedChanges();
if (!$this->shouldRequireCleanUntrackedFiles()) {
$untracked = array();
}
if ($untracked) {
echo sprintf(
"%s\n\n%s",
pht('You have untracked files in this working copy.'),
$working_copy_desc);
if ($api instanceof ArcanistGitAPI) {
$hint = pht(
'(To ignore these %s change(s), add them to "%s".)',
phutil_count($untracked),
'.git/info/exclude');
} else if ($api instanceof ArcanistSubversionAPI) {
$hint = pht(
'(To ignore these %s change(s), add them to "%s".)',
phutil_count($untracked),
'svn:ignore');
} else if ($api instanceof ArcanistMercurialAPI) {
$hint = pht(
'(To ignore these %s change(s), add them to "%s".)',
phutil_count($untracked),
'.hgignore');
}
$untracked_list = " ".implode("\n ", $untracked);
echo sprintf(
" %s\n %s\n%s",
pht('Untracked changes in working copy:'),
$hint,
$untracked_list);
$prompt = pht(
'Ignore these %s untracked file(s) and continue?',
phutil_count($untracked));
if (!phutil_console_confirm($prompt)) {
throw new ArcanistUserAbortException();
}
}
$should_commit = false;
if ($unstaged || $uncommitted) {
// NOTE: We're running this because it builds a cache and can take a
// perceptible amount of time to arrive at an answer, but we don't want
// to pause in the middle of printing the output below.
$this->getShouldAmend();
echo sprintf(
"%s\n\n%s",
pht('You have uncommitted changes in this working copy.'),
$working_copy_desc);
$lists = array();
if ($unstaged) {
$unstaged_list = " ".implode("\n ", $unstaged);
$lists[] = sprintf(
" %s\n%s",
pht('Unstaged changes in working copy:'),
$unstaged_list);
}
if ($uncommitted) {
$uncommitted_list = " ".implode("\n ", $uncommitted);
$lists[] = sprintf(
"%s\n%s",
pht('Uncommitted changes in working copy:'),
$uncommitted_list);
}
echo implode("\n\n", $lists)."\n";
$all_uncommitted = array_merge($unstaged, $uncommitted);
if ($this->askForAdd($all_uncommitted)) {
if ($unstaged) {
$api->addToCommit($unstaged);
}
$should_commit = true;
} else {
$permit_autostash = $this->getConfigFromAnySource('arc.autostash');
if ($permit_autostash && $api->canStashChanges()) {
echo pht(
'Stashing uncommitted changes. (You can restore them with `%s`).',
'git stash pop')."\n";
$api->stashChanges();
$this->stashed = true;
} else {
throw new ArcanistUsageException(
pht(
'You can not continue with uncommitted changes. '.
'Commit or discard them before proceeding.'));
}
}
}
if ($should_commit) {
if ($this->getShouldAmend()) {
$commit = head($api->getLocalCommitInformation());
$api->amendCommit($commit['message']);
} else if ($api->supportsLocalCommits()) {
$template = sprintf(
"\n\n# %s\n#\n# %s\n#\n",
pht('Enter a commit message.'),
pht('Changes:'));
$paths = array_merge($uncommitted, $unstaged);
$paths = array_unique($paths);
sort($paths);
foreach ($paths as $path) {
$template .= "# ".$path."\n";
}
$commit_message = $this->newInteractiveEditor($template)
->setName(pht('commit-message'))
->setTaskMessage(pht(
'Supply commit message for uncommitted changes, then save and '.
'exit.'))
->editInteractively();
if ($commit_message === $template) {
throw new ArcanistUsageException(
pht('You must provide a commit message.'));
}
$commit_message = ArcanistCommentRemover::removeComments(
$commit_message);
if (!strlen($commit_message)) {
throw new ArcanistUsageException(
pht('You must provide a nonempty commit message.'));
}
$api->doCommit($commit_message);
}
}
}
private function getShouldAmend() {
if ($this->shouldAmend === null) {
$this->shouldAmend = $this->calculateShouldAmend();
}
return $this->shouldAmend;
}
private function calculateShouldAmend() {
$api = $this->getRepositoryAPI();
if ($this->isHistoryImmutable() || !$api->supportsAmend()) {
return false;
}
$commits = $api->getLocalCommitInformation();
if (!$commits) {
return false;
}
$commit = reset($commits);
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$commit['message']);
if ($message->getGitSVNBaseRevision()) {
return false;
}
if ($api->getAuthor() != $commit['author']) {
return false;
}
if ($message->getRevisionID() && $this->getArgument('create')) {
return false;
}
// TODO: Check commits since tracking branch. If empty then return false.
// Don't amend the current commit if it has already been published.
$repository = $this->loadProjectRepository();
if ($repository) {
$repo_id = $repository['id'];
$commit_hash = $commit['commit'];
$callsign = idx($repository, 'callsign');
if ($callsign) {
// The server might be too old to support the new style commit names,
// so prefer the old way
$commit_name = "r{$callsign}{$commit_hash}";
} else {
$commit_name = "R{$repo_id}:{$commit_hash}";
}
$result = $this->getConduit()->callMethodSynchronous(
'diffusion.querycommits',
array('names' => array($commit_name)));
$known_commit = idx($result['identifierMap'], $commit_name);
if ($known_commit) {
return false;
}
}
if (!$message->getRevisionID()) {
return true;
}
$in_working_copy = $api->loadWorkingCopyDifferentialRevisions(
$this->getConduit(),
array(
'authors' => array($this->getUserPHID()),
'status' => 'status-open',
));
if ($in_working_copy) {
return true;
}
return false;
}
private function askForAdd(array $files) {
if ($this->commitMode == self::COMMIT_DISABLE) {
return false;
}
if ($this->commitMode == self::COMMIT_ENABLE) {
return true;
}
$prompt = $this->getAskForAddPrompt($files);
return phutil_console_confirm($prompt);
}
private function getAskForAddPrompt(array $files) {
if ($this->getShouldAmend()) {
$prompt = pht(
'Do you want to amend these %s change(s) to the current commit?',
phutil_count($files));
} else {
$prompt = pht(
'Do you want to create a new commit with these %s change(s)?',
phutil_count($files));
}
return $prompt;
}
final protected function loadDiffBundleFromConduit(
ConduitClient $conduit,
$diff_id) {
return $this->loadBundleFromConduit(
$conduit,
array(
'ids' => array($diff_id),
));
}
final protected function loadRevisionBundleFromConduit(
ConduitClient $conduit,
$revision_id) {
return $this->loadBundleFromConduit(
$conduit,
array(
'revisionIDs' => array($revision_id),
));
}
private function loadBundleFromConduit(
ConduitClient $conduit,
$params) {
$future = $conduit->callMethod('differential.querydiffs', $params);
$diff = head($future->resolve());
if ($diff == null) {
throw new Exception(
phutil_console_wrap(
pht("The diff or revision you specified is either invalid or you ".
"don't have permission to view it."))
);
}
$changes = array();
foreach ($diff['changes'] as $changedict) {
$changes[] = ArcanistDiffChange::newFromDictionary($changedict);
}
$bundle = ArcanistBundle::newFromChanges($changes);
$bundle->setConduit($conduit);
// since the conduit method has changes, assume that these fields
// could be unset
$bundle->setBaseRevision(idx($diff, 'sourceControlBaseRevision'));
$bundle->setRevisionID(idx($diff, 'revisionID'));
$bundle->setAuthorName(idx($diff, 'authorName'));
$bundle->setAuthorEmail(idx($diff, 'authorEmail'));
return $bundle;
}
/**
* Return a list of lines changed by the current diff, or ##null## if the
* change list is meaningless (for example, because the path is a directory
* or binary file).
*
* @param string $path Path within the repository.
* @param string $mode Change selection mode (see ArcanistDiffHunk).
* @return list|null List of changed line numbers, or null to indicate that
* the path is not a line-oriented text file.
*/
final protected function getChangedLines($path, $mode) {
$repository_api = $this->getRepositoryAPI();
$full_path = $repository_api->getPath($path);
if (is_dir($full_path)) {
return null;
}
if (!file_exists($full_path)) {
return null;
}
$change = $this->getChange($path);
if ($change->getFileType() !== ArcanistDiffChangeType::FILE_TEXT) {
return null;
}
$lines = $change->getChangedLines($mode);
return array_keys($lines);
}
final protected function getChange($path) {
$repository_api = $this->getRepositoryAPI();
// TODO: Very gross
$is_git = ($repository_api instanceof ArcanistGitAPI);
$is_hg = ($repository_api instanceof ArcanistMercurialAPI);
$is_svn = ($repository_api instanceof ArcanistSubversionAPI);
if ($is_svn) {
// NOTE: In SVN, we don't currently support a "get all local changes"
// operation, so special case it.
if (empty($this->changeCache[$path])) {
$diff = $repository_api->getRawDiffText($path);
$parser = $this->newDiffParser();
$changes = $parser->parseDiff($diff);
if (count($changes) != 1) {
throw new Exception(pht('Expected exactly one change.'));
}
$this->changeCache[$path] = reset($changes);
}
} else if ($is_git || $is_hg) {
if (empty($this->changeCache)) {
$changes = $repository_api->getAllLocalChanges();
foreach ($changes as $change) {
$this->changeCache[$change->getCurrentPath()] = $change;
}
}
} else {
throw new Exception(pht('Missing VCS support.'));
}
if (empty($this->changeCache[$path])) {
if ($is_git || $is_hg) {
// This can legitimately occur under git/hg if you make a change,
// "git/hg commit" it, and then revert the change in the working copy
// and run "arc lint".
$change = new ArcanistDiffChange();
$change->setCurrentPath($path);
return $change;
} else {
throw new Exception(
pht(
"Trying to get change for unchanged path '%s'!",
$path));
}
}
return $this->changeCache[$path];
}
final public function willRunWorkflow() {
$spec = $this->getCompleteArgumentSpecification();
foreach ($this->arguments as $arg => $value) {
if (empty($spec[$arg])) {
continue;
}
$options = $spec[$arg];
if (!empty($options['supports'])) {
$system_name = $this->getRepositoryAPI()->getSourceControlSystemName();
if (!in_array($system_name, $options['supports'])) {
$extended_info = null;
if (!empty($options['nosupport'][$system_name])) {
$extended_info = ' '.$options['nosupport'][$system_name];
}
throw new ArcanistUsageException(
pht(
"Option '%s' is not supported under %s.",
"--{$arg}",
$system_name).
$extended_info);
}
}
}
}
/**
* @param string|null $revision_id
* @return string
*/
final protected function normalizeRevisionID($revision_id) {
if ($revision_id === null) {
return '';
}
return preg_replace('/^D/i', '', $revision_id);
}
protected function shouldShellComplete() {
return true;
}
protected function getShellCompletions(array $argv) {
return array();
}
public function getSupportedRevisionControlSystems() {
return array('git', 'hg', 'svn');
}
final protected function getPassthruArgumentsAsMap($command) {
$map = array();
foreach ($this->getCompleteArgumentSpecification() as $key => $spec) {
if (!empty($spec['passthru'][$command])) {
if (isset($this->arguments[$key])) {
$map[$key] = $this->arguments[$key];
}
}
}
return $map;
}
final protected function getPassthruArgumentsAsArgv($command) {
$spec = $this->getCompleteArgumentSpecification();
$map = $this->getPassthruArgumentsAsMap($command);
$argv = array();
foreach ($map as $key => $value) {
$argv[] = '--'.$key;
if (!empty($spec[$key]['param'])) {
$argv[] = $value;
}
}
return $argv;
}
/**
* Write a message to stderr so that '--json' flags or stdout which is meant
* to be piped somewhere aren't disrupted.
*
* @param string $msg Message to write to stderr.
* @return void
*/
final protected function writeStatusMessage($msg) {
PhutilSystem::writeStderr($msg);
}
final public function writeInfo($title, $message) {
$this->writeStatusMessage(
phutil_console_format(
"<bg:blue>** %s **</bg> %s\n",
$title,
$message));
}
final public function writeWarn($title, $message) {
$this->writeStatusMessage(
phutil_console_format(
"<bg:yellow>** %s **</bg> %s\n",
$title,
$message));
}
final public function writeOkay($title, $message) {
$this->writeStatusMessage(
phutil_console_format(
"<bg:green>** %s **</bg> %s\n",
$title,
$message));
}
final protected function isHistoryImmutable() {
$repository_api = $this->getRepositoryAPI();
$config = $this->getConfigFromAnySource('history.immutable');
if ($config !== null) {
return $config;
}
return $repository_api->isHistoryDefaultImmutable();
}
/**
* Workflows like 'lint' and 'unit' operate on a list of working copy paths.
* The user can either specify the paths explicitly ("a.js b.php"), or by
* specifying a revision ("--rev a3f10f1f") to select all paths modified
* since that revision, or by omitting both and letting arc choose the
* default relative revision.
*
* This method takes the user's selections and returns the paths that the
* workflow should act upon.
*
* @param list $paths List of explicitly provided paths.
* @param string|null $rev Revision name, if provided.
* @param mask $omit_mask (optional) Mask of ArcanistRepositoryAPI
* flags to exclude.
* Defaults to ArcanistRepositoryAPI::FLAG_UNTRACKED.
* @return list List of paths the workflow should act on.
*/
final protected function selectPathsForWorkflow(
array $paths,
$rev,
$omit_mask = null) {
if ($omit_mask === null) {
$omit_mask = ArcanistRepositoryAPI::FLAG_UNTRACKED;
}
if ($paths) {
$working_copy = $this->getWorkingCopyIdentity();
foreach ($paths as $key => $path) {
$full_path = Filesystem::resolvePath($path);
if (!Filesystem::pathExists($full_path)) {
throw new ArcanistUsageException(
pht(
"Path '%s' does not exist!",
$path));
}
$relative_path = Filesystem::readablePath(
$full_path,
$working_copy->getProjectRoot());
$paths[$key] = $relative_path;
}
} else {
$repository_api = $this->getRepositoryAPI();
if ($rev) {
$this->parseBaseCommitArgument(array($rev));
}
$paths = $repository_api->getWorkingCopyStatus();
foreach ($paths as $path => $flags) {
if ($flags & $omit_mask) {
unset($paths[$path]);
}
}
$paths = array_keys($paths);
}
return array_values($paths);
}
final protected function renderRevisionList(array $revisions) {
$list = array();
foreach ($revisions as $revision) {
$list[] = ' - D'.$revision['id'].': '.$revision['title']."\n";
}
return implode('', $list);
}
/* -( Scratch Files )------------------------------------------------------ */
/**
* Try to read a scratch file, if it exists and is readable.
*
* @param string $path Scratch file name.
* @return mixed String for file contents, or false for failure.
* @task scratch
*/
final protected function readScratchFile($path) {
if (!$this->repositoryAPI) {
return false;
}
return $this->getRepositoryAPI()->readScratchFile($path);
}
/**
* Try to read a scratch JSON file, if it exists and is readable.
*
* @param string $path Scratch file name.
* @return array Empty array for failure.
* @task scratch
*/
final protected function readScratchJSONFile($path) {
$file = $this->readScratchFile($path);
if (!$file) {
return array();
}
return phutil_json_decode($file);
}
/**
* Try to write a scratch file, if there's somewhere to put it and we can
* write there.
*
* @param string $path Scratch file name to write.
* @param string $data Data to write.
* @return bool True on success, false on failure.
* @task scratch
*/
final protected function writeScratchFile($path, $data) {
if (!$this->repositoryAPI) {
return false;
}
return $this->getRepositoryAPI()->writeScratchFile($path, $data);
}
/**
* Try to write a scratch JSON file, if there's somewhere to put it and we can
* write there.
*
* @param string $path Scratch file name to write.
* @param array $data Data to write.
* @return bool True on success, false on failure.
* @task scratch
*/
final protected function writeScratchJSONFile($path, array $data) {
return $this->writeScratchFile($path, json_encode($data));
}
/**
* Try to remove a scratch file.
*
* @param string $path Scratch file name to remove.
* @return bool True if the file was removed successfully.
* @task scratch
*/
final protected function removeScratchFile($path) {
if (!$this->repositoryAPI) {
return false;
}
return $this->getRepositoryAPI()->removeScratchFile($path);
}
/**
* Get a human-readable description of the scratch file location.
*
* @param string $path Scratch file name.
* @return mixed String, or false on failure.
* @task scratch
*/
final protected function getReadableScratchFilePath($path) {
if (!$this->repositoryAPI) {
return false;
}
return $this->getRepositoryAPI()->getReadableScratchFilePath($path);
}
/**
* Get the path to a scratch file, if possible.
*
* @param string $path Scratch file name.
* @return mixed File path, or false on failure.
* @task scratch
*/
final protected function getScratchFilePath($path) {
if (!$this->repositoryAPI) {
return false;
}
return $this->getRepositoryAPI()->getScratchFilePath($path);
}
final protected function getRepositoryEncoding() {
return nonempty(
idx($this->loadProjectRepository(), 'encoding'),
'UTF-8');
}
final protected function loadProjectRepository() {
list($info, $reasons) = $this->loadRepositoryInformation();
return coalesce($info, array());
}
final protected function newInteractiveEditor($text) {
$editor = new PhutilInteractiveEditor($text);
$preferred = $this->getConfigFromAnySource('editor');
if ($preferred) {
$editor->setPreferredEditor($preferred);
}
return $editor;
}
final protected function newDiffParser() {
$parser = new ArcanistDiffParser();
if ($this->repositoryAPI) {
$parser->setRepositoryAPI($this->getRepositoryAPI());
}
$parser->setWriteDiffOnFailure(true);
return $parser;
}
final protected function dispatchEvent($type, array $data) {
$data += array(
'workflow' => $this,
);
$event = new PhutilEvent($type, $data);
PhutilEventEngine::dispatchEvent($event);
return $event;
}
final public function parseBaseCommitArgument(array $argv) {
if (!count($argv)) {
return;
}
$api = $this->getRepositoryAPI();
if (!$api->supportsCommitRanges()) {
throw new ArcanistUsageException(
pht('This version control system does not support commit ranges.'));
}
if (count($argv) > 1) {
throw new ArcanistUsageException(
pht(
'Specify exactly one base commit. The end of the commit range is '.
'always the working copy state.'));
}
$api->setBaseCommit(head($argv));
return $this;
}
final protected function getRepositoryVersion() {
if (!$this->repositoryVersion) {
$api = $this->getRepositoryAPI();
$commit = $api->getSourceControlBaseRevision();
$versions = array('' => $commit);
foreach ($api->getChangedFiles($commit) as $path => $mask) {
$versions[$path] = (Filesystem::pathExists($path)
? md5_file($path)
: '');
}
$this->repositoryVersion = md5(json_encode($versions));
}
return $this->repositoryVersion;
}
/* -( Phabricator Repositories )------------------------------------------- */
/**
* Get the PHID of the Phabricator repository this working copy corresponds
* to. Returns `null` if no repository can be identified.
*
* @return phid|null Repository PHID, or null if no repository can be
* identified.
*
* @task phabrep
*/
final protected function getRepositoryPHID() {
return idx($this->getRepositoryInformation(), 'phid');
}
/**
* Get the name of the Phabricator repository this working copy
* corresponds to. Returns `null` if no repository can be identified.
*
* @return string|null Repository name, or null if no repository can be
* identified.
*
* @task phabrep
*/
final protected function getRepositoryName() {
return idx($this->getRepositoryInformation(), 'name');
}
/**
* Get the URI of the Phabricator repository this working copy
* corresponds to. Returns `null` if no repository can be identified.
*
* @return string|null Repository URI, or null if no repository can be
* identified.
*
* @task phabrep
*/
final protected function getRepositoryURI() {
return idx($this->getRepositoryInformation(), 'uri');
}
final protected function getRepositoryStagingConfiguration() {
return idx($this->getRepositoryInformation(), 'staging');
}
/**
* Get human-readable reasoning explaining how `arc` evaluated which
* Phabricator repository corresponds to this working copy. Used by
* `arc which` to explain the process to users.
*
* @return list<string> Human-readable explanation of the repository
* association process.
*
* @task phabrep
*/
final protected function getRepositoryReasons() {
$this->getRepositoryInformation();
return $this->repositoryReasons;
}
/**
* @task phabrep
*/
private function getRepositoryInformation() {
if ($this->repositoryInfo === null) {
list($info, $reasons) = $this->loadRepositoryInformation();
$this->repositoryInfo = nonempty($info, array());
$this->repositoryReasons = $reasons;
}
return $this->repositoryInfo;
}
/**
* @task phabrep
*/
private function loadRepositoryInformation() {
list($query, $reasons) = $this->getRepositoryQuery();
if (!$query) {
return array(null, $reasons);
}
try {
$method = 'repository.query';
$results = $this->getConduitEngine()
->newFuture($method, $query)
->resolve();
} catch (ConduitClientException $ex) {
if ($ex->getErrorCode() == 'ERR-CONDUIT-CALL') {
$reasons[] = pht(
'This software version on the server you are connecting to is out '.
'of date and does not have support for identifying repositories '.
'by callsign or URI. Update the server sofwware to enable these '.
'features.');
return array(null, $reasons);
}
throw $ex;
}
$result = null;
if (!$results) {
$reasons[] = pht(
'No repositories matched the query. Check that your configuration '.
'is correct, or use "%s" to select a repository explicitly.',
'repository.callsign');
} else if (count($results) > 1) {
$reasons[] = pht(
'Multiple repostories (%s) matched the query. You can use the '.
'"%s" configuration to select the one you want.',
implode(', ', ipull($results, 'callsign')),
'repository.callsign');
} else {
$result = head($results);
$reasons[] = pht('Found a unique matching repository.');
}
return array($result, $reasons);
}
/**
* @task phabrep
*/
private function getRepositoryQuery() {
$reasons = array();
$callsign = $this->getConfigFromAnySource('repository.callsign');
if ($callsign) {
$query = array(
'callsigns' => array($callsign),
);
$reasons[] = pht(
'Configuration value "%s" is set to "%s".',
'repository.callsign',
$callsign);
return array($query, $reasons);
} else {
$reasons[] = pht(
'Configuration value "%s" is empty.',
'repository.callsign');
}
$uuid = $this->getRepositoryAPI()->getRepositoryUUID();
if ($uuid !== null) {
$query = array(
'uuids' => array($uuid),
);
$reasons[] = pht(
'The UUID for this working copy is "%s".',
$uuid);
return array($query, $reasons);
} else {
$reasons[] = pht(
'This repository has no VCS UUID (this is normal for git/hg).');
}
// TODO: Swap this for a RemoteRefQuery.
$remote_uri = $this->getRepositoryAPI()->getRemoteURI();
if ($remote_uri !== null) {
$query = array(
'remoteURIs' => array($remote_uri),
);
$reasons[] = pht(
'The remote URI for this working copy is "%s".',
$remote_uri);
return array($query, $reasons);
} else {
$reasons[] = pht(
'Unable to determine the remote URI for this repository.');
}
return array(null, $reasons);
}
/**
* Build a new lint engine for the current working copy.
*
* Optionally, you can pass an explicit engine class name to build an engine
* of a particular class. Normally this is used to implement an `--engine`
* flag from the CLI.
*
* @param string $engine_class (optional) Explicit engine class name.
* @return ArcanistLintEngine Constructed engine.
*/
protected function newLintEngine($engine_class = null) {
$working_copy = $this->getWorkingCopyIdentity();
$config = $this->getConfigurationManager();
if (!$engine_class) {
$engine_class = $config->getConfigFromAnySource('lint.engine');
}
if (!$engine_class) {
if (Filesystem::pathExists($working_copy->getProjectPath('.arclint'))) {
$engine_class = 'ArcanistConfigurationDrivenLintEngine';
}
}
if (!$engine_class) {
throw new ArcanistNoEngineException(
pht(
"No lint engine is configured for this project. Create an '%s' ".
"file, or configure an advanced engine with '%s' in '%s'.",
'.arclint',
'lint.engine',
'.arcconfig'));
}
$base_class = 'ArcanistLintEngine';
if (!class_exists($engine_class) ||
!is_subclass_of($engine_class, $base_class)) {
throw new ArcanistUsageException(
pht(
'Configured lint engine "%s" is not a subclass of "%s", but must be.',
$engine_class,
$base_class));
}
$engine = newv($engine_class, array())
->setWorkingCopy($working_copy)
->setConfigurationManager($config);
return $engine;
}
/**
* Build a new unit test engine for the current working copy.
*
* Optionally, you can pass an explicit engine class name to build an engine
* of a particular class. Normally this is used to implement an `--engine`
* flag from the CLI.
*
* @param string $engine_class (optional) Explicit engine class name.
* @return ArcanistUnitTestEngine Constructed engine.
*/
protected function newUnitTestEngine($engine_class = null) {
$working_copy = $this->getWorkingCopyIdentity();
$config = $this->getConfigurationManager();
if (!$engine_class) {
$engine_class = $config->getConfigFromAnySource('unit.engine');
}
if (!$engine_class) {
if (Filesystem::pathExists($working_copy->getProjectPath('.arcunit'))) {
$engine_class = 'ArcanistConfigurationDrivenUnitTestEngine';
}
}
if (!$engine_class) {
throw new ArcanistNoEngineException(
pht(
"No unit test engine is configured for this project. Create an ".
"'%s' file, or configure an advanced engine with '%s' in '%s'.",
'.arcunit',
'unit.engine',
'.arcconfig'));
}
$base_class = 'ArcanistUnitTestEngine';
if (!class_exists($engine_class) ||
!is_subclass_of($engine_class, $base_class)) {
throw new ArcanistUsageException(
pht(
'Configured unit test engine "%s" is not a subclass of "%s", '.
'but must be.',
$engine_class,
$base_class));
}
$engine = newv($engine_class, array())
->setWorkingCopy($working_copy)
->setConfigurationManager($config);
return $engine;
}
protected function openURIsInBrowser(array $uris) {
$browser = $this->getBrowserCommand();
// The "browser" may actually be a list of arguments.
if (!is_array($browser)) {
$browser = array($browser);
}
foreach ($uris as $uri) {
$err = phutil_passthru('%LR %R', $browser, $uri);
if ($err) {
throw new ArcanistUsageException(
pht(
'Failed to open URI "%s" in browser ("%s"). '.
'Check your "browser" config option.',
$uri,
implode(' ', $browser)));
}
}
}
private function getBrowserCommand() {
$config = $this->getConfigFromAnySource('browser');
if ($config) {
return $config;
}
if (phutil_is_windows()) {
// See T13504. We now use "bypass_shell", so "start" alone is no longer
// a valid binary to invoke directly.
return array(
'cmd',
'/c',
'start',
);
}
$candidates = array(
'sensible-browser' => array('sensible-browser'),
'xdg-open' => array('xdg-open'),
'open' => array('open', '--'),
);
// NOTE: The "open" command works well on OS X, but on many Linuxes "open"
// exists and is not a browser. For now, we're just looking for other
// commands first, but we might want to be smarter about selecting "open"
// only on OS X.
foreach ($candidates as $cmd => $argv) {
if (Filesystem::binaryExists($cmd)) {
return $argv;
}
}
throw new ArcanistUsageException(
pht(
'Unable to find a browser command to run. Set "browser" in your '.
'configuration to specify a command to use.'));
}
/**
* Ask Phabricator to update the current repository as soon as possible.
*
* Calling this method after pushing commits allows Phabricator to discover
* the commits more quickly, so the system overall is more responsive.
*
* @return void
*/
protected function askForRepositoryUpdate() {
// If we know which repository we're in, try to tell Phabricator that we
// pushed commits to it so it can update. This hint can help pull updates
// more quickly, especially in rarely-used repositories.
if ($this->getRepositoryPHID()) {
try {
$this->getConduit()->callMethodSynchronous(
'diffusion.looksoon',
array(
'repositories' => array($this->getRepositoryPHID()),
));
} catch (ConduitClientException $ex) {
// If we hit an exception, just ignore it. Likely, we are running
// against a Phabricator which is too old to support this method.
// Since this hint is purely advisory, it doesn't matter if it has
// no effect.
}
}
}
protected function getModernLintDictionary(array $map) {
$map = $this->getModernCommonDictionary($map);
return $map;
}
protected function getModernUnitDictionary(array $map) {
$map = $this->getModernCommonDictionary($map);
$details = idx($map, 'userData');
if (phutil_nonempty_string($details)) {
$map['details'] = (string)$details;
}
unset($map['userData']);
return $map;
}
private function getModernCommonDictionary(array $map) {
foreach ($map as $key => $value) {
if ($value === null) {
unset($map[$key]);
}
}
return $map;
}
final public function setConduitEngine(
ArcanistConduitEngine $conduit_engine) {
$this->conduitEngine = $conduit_engine;
return $this;
}
final public function getConduitEngine() {
return $this->conduitEngine;
}
final public function getRepositoryRef() {
$configuration_engine = $this->getConfigurationEngine();
if ($configuration_engine) {
// This is a toolset workflow and can always build a repository ref.
} else {
if (!$this->getConfigurationManager()->getWorkingCopyIdentity()) {
return null;
}
if (!$this->repositoryAPI) {
return null;
}
}
if (!$this->repositoryRef) {
$ref = id(new ArcanistRepositoryRef())
->setPHID($this->getRepositoryPHID())
->setBrowseURI($this->getRepositoryURI());
$this->repositoryRef = $ref;
}
return $this->repositoryRef;
}
final public function getToolsetKey() {
return $this->getToolset()->getToolsetKey();
}
final public function getConfig($key) {
return $this->getConfigurationSourceList()->getConfig($key);
}
public function canHandleSignal($signo) {
return false;
}
public function handleSignal($signo) {
return;
}
final public function newCommand(PhutilExecutableFuture $future) {
return id(new ArcanistCommand())
->setLogEngine($this->getLogEngine())
->setExecutableFuture($future);
}
final public function loadHardpoints(
$objects,
$requests) {
return $this->getRuntime()->loadHardpoints($objects, $requests);
}
protected function newPrompts() {
return array();
}
protected function newPrompt($key) {
return id(new ArcanistPrompt())
->setWorkflow($this)
->setKey($key);
}
public function hasPrompt($key) {
$map = $this->getPromptMap();
return isset($map[$key]);
}
public function getPromptMap() {
if ($this->promptMap === null) {
$prompts = $this->newPrompts();
assert_instances_of($prompts, 'ArcanistPrompt');
// TODO: Move this somewhere modular.
$prompts[] = $this->newPrompt('arc.state.stash')
->setDescription(
pht(
'Prompts the user to stash changes and continue when the '.
'working copy has untracked, uncommitted, or unstaged '.
'changes.'));
// TODO: Swap to ArrayCheck?
$map = array();
foreach ($prompts as $prompt) {
$key = $prompt->getKey();
if (isset($map[$key])) {
throw new Exception(
pht(
'Workflow ("%s") generates two prompts with the same '.
'key ("%s"). Each prompt a workflow generates must have a '.
'unique key.',
get_class($this),
$key));
}
$map[$key] = $prompt;
}
$this->promptMap = $map;
}
return $this->promptMap;
}
final public function getPrompt($key) {
$map = $this->getPromptMap();
$prompt = idx($map, $key);
if (!$prompt) {
throw new Exception(
pht(
'Workflow ("%s") is requesting a prompt ("%s") but it did not '.
'generate any prompt with that name in "newPrompts()".',
get_class($this),
$key));
}
return clone $prompt;
}
final protected function getSymbolEngine() {
return $this->getRuntime()->getSymbolEngine();
}
final protected function getViewer() {
return $this->getRuntime()->getViewer();
}
final protected function readStdin() {
$log = $this->getLogEngine();
$log->writeWaitingForInput();
// NOTE: We can't just "file_get_contents()" here because signals don't
// interrupt it. If the user types "^C", we want to interrupt the read.
$raw_handle = fopen('php://stdin', 'rb');
$stdin = new PhutilSocketChannel($raw_handle);
while ($stdin->update()) {
PhutilChannel::waitForAny(array($stdin));
}
return $stdin->read();
}
final public function getAbsoluteURI($raw_uri) {
// TODO: "ArcanistRevisionRef", at least, may return a relative URI.
// If we get a relative URI, guess the correct absolute URI based on
// the Conduit URI. This might not be correct for Conduit over SSH.
$raw_uri = new PhutilURI($raw_uri);
if (!strlen($raw_uri->getDomain())) {
$base_uri = $this->getConduitEngine()
->getConduitURI();
$raw_uri = id(new PhutilURI($base_uri))
->setPath($raw_uri->getPath());
}
$raw_uri = phutil_string_cast($raw_uri);
return $raw_uri;
}
final public function writeToPager($corpus) {
$is_tty = (function_exists('posix_isatty') && posix_isatty(STDOUT));
if (!$is_tty) {
echo $corpus;
} else {
$pager = $this->getConfig('pager');
if (!$pager) {
$pager = array('less', '-R', '--');
}
// Try to show the content through a pager.
$err = id(new PhutilExecPassthru('%Ls', $pager))
->write($corpus)
->resolve();
// If the pager exits with an error, print the content normally.
if ($err) {
echo $corpus;
}
}
return $this;
}
}

File Metadata

Mime Type
text/x-diff
Expires
Thu, Dec 19, 17:00 (22 h, 25 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1014852
Default Alt Text
(435 KB)

Event Timeline