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 65ea544b..2827de10 100644
--- a/src/channel/PhutilChannel.php
+++ b/src/channel/PhutilChannel.php
@@ -1,426 +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 Data to write to the channel, normally bytes.
+ * @param wild $bytes Data to write to the channel, normally bytes.
* @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> A list of channels to wait for.
- * @param dict Options, see above.
+ * @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> List of channels to wait for reads on.
- * @param list<PhutilChannel> List of channels to wait for writes on.
+ * @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 Channel name.
+ * @param string $name Channel name.
* @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 Maximum number of bytes to read.
+ * @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 to write.
+ * @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 Maximum read buffer size, or `null` for a limitless buffer.
+ * @param int|null $size Maximum read buffer size, or `null` for a limitless
+ * buffer.
* @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 0d4e722b..0f862103 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 to use as an underlying I/O source.
+ * @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 to invoke when stderr data is received.
+ * @param callable $handler Handler to invoke when stderr data is received.
* @return this
*/
public function setStderrHandler($handler) {
$this->stderrHandler = $handler;
return $this;
}
}
diff --git a/src/channel/PhutilProtocolChannel.php b/src/channel/PhutilProtocolChannel.php
index 2e018fd8..1670857e 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 Some message.
+ * @param wild $message Some message.
* @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 Some message.
+ * @param wild $message Some message.
* @return this
*
* @task io
*/
public function addMessage($message) {
$this->messages[] = $message;
return $this;
}
/* -( Protocol Implementation )-------------------------------------------- */
/**
* Encode a message for transmission.
*
- * @param wild Some message.
+ * @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 One or more bytes from the underlying channel.
+ * @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/channel/PhutilSocketChannel.php b/src/channel/PhutilSocketChannel.php
index e8cf9cf1..41cd2ddc 100644
--- a/src/channel/PhutilSocketChannel.php
+++ b/src/channel/PhutilSocketChannel.php
@@ -1,192 +1,193 @@
<?php
/**
* Channel on an underlying stream socket or socket pair. For a description of
* channels, see @{class:PhutilChannel}.
*
* Using a network socket:
*
* $socket = stream_socket_client(...);
* $channel = new PhutilSocketChannel($socket);
*
* Using stdin/stdout:
*
* $channel = new PhutilSocketChannel(
* fopen('php://stdin', 'r'),
* fopen('php://stdout', 'w'));
*
* @task construct Construction
*/
final class PhutilSocketChannel extends PhutilChannel {
private $readSocket;
private $writeSocket;
private $isSingleSocket;
/* -( Construction )------------------------------------------------------- */
/**
* Construct a socket channel from a socket or a socket pair.
*
* NOTE: This must be a //stream// socket from `stream_socket_client()` or
* `stream_socket_server()` or similar, not a //plain// socket from
* `socket_create()` or similar.
*
- * @param socket Socket (stream socket, not plain socket). If only one
- * socket is provided, it is used for reading and writing.
- * @param socket (optional) Write socket.
+ * @param socket $read_socket Socket (stream socket, not plain socket). If
+ * only one socket is provided, it is used for reading and
+ * writing.
+ * @param socket $write_socket (optional) Write socket.
*
* @task construct
*/
public function __construct($read_socket, $write_socket = null) {
parent::__construct();
foreach (array($read_socket, $write_socket) as $socket) {
if (!$socket) {
continue;
}
$ok = stream_set_blocking($socket, false);
if (!$ok) {
throw new Exception(pht('Failed to set socket nonblocking!'));
}
}
$this->readSocket = $read_socket;
if ($write_socket) {
$this->writeSocket = $write_socket;
} else {
$this->writeSocket = $read_socket;
$this->isSingleSocket = true;
}
}
public function __destruct() {
$this->closeSockets();
}
/**
* Creates a pair of socket channels that are connected to each other. This
* is mostly useful for writing unit tests of, e.g., protocol channels.
*
* list($x, $y) = PhutilSocketChannel::newChannelPair();
*
* @task construct
*/
public static function newChannelPair() {
$sockets = null;
$domain = phutil_is_windows() ? STREAM_PF_INET : STREAM_PF_UNIX;
$pair = stream_socket_pair($domain, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
if (!$pair) {
throw new Exception(pht('%s failed!', 'stream_socket_pair()'));
}
$x = new PhutilSocketChannel($pair[0]);
$y = new PhutilSocketChannel($pair[1]);
return array($x, $y);
}
public function isOpen() {
return ($this->isOpenForReading() || $this->isOpenForWriting());
}
public function isOpenForReading() {
return (bool)$this->readSocket;
}
public function isOpenForWriting() {
return (bool)$this->writeSocket;
}
protected function readBytes($length) {
$socket = $this->readSocket;
if (!$socket) {
return '';
}
$data = @fread($socket, min($length, 64 * 1024));
if ($data === false) {
$this->closeReadSocket();
$data = '';
}
// NOTE: fread() continues returning empty string after the socket is
// closed, we need to check for EOF explicitly.
if ($data === '') {
if (feof($socket)) {
$this->closeReadSocket();
}
}
return $data;
}
protected function writeBytes($bytes) {
$socket = $this->writeSocket;
if (!$socket) {
return 0;
}
$len = phutil_fwrite_nonblocking_stream($socket, $bytes);
if ($len === false) {
$this->closeWriteSocket();
return 0;
}
return $len;
}
protected function getReadSockets() {
if ($this->readSocket) {
return array($this->readSocket);
}
return array();
}
protected function getWriteSockets() {
if ($this->writeSocket) {
return array($this->writeSocket);
} else {
return array();
}
}
private function closeReadSocket() {
$this->closeOneSocket($this->readSocket);
$this->readSocket = null;
if ($this->isSingleSocket) {
$this->writeSocket = null;
}
}
private function closeWriteSocket() {
$this->closeOneSocket($this->writeSocket);
$this->writeSocket = null;
if ($this->isSingleSocket) {
$this->readSocket = null;
}
}
public function closeWriteChannel() {
$this->closeWriteSocket();
}
private function closeOneSocket($socket) {
if (!$socket) {
return;
}
// We should also stream_socket_shutdown() here but HHVM throws errors
// with it (for example 'Unexpected object type PlainFile'). We depend
// just on fclose() until it is fixed.
@fclose($socket);
}
private function closeSockets() {
$this->closeReadSocket();
$this->closeWriteSocket();
}
}
diff --git a/src/conduit/ConduitClient.php b/src/conduit/ConduitClient.php
index 441fe25c..21db1427 100644
--- a/src/conduit/ConduitClient.php
+++ b/src/conduit/ConduitClient.php
@@ -1,417 +1,417 @@
<?php
final class ConduitClient extends Phobject {
private $uri;
private $host;
private $connectionID;
private $sessionKey;
private $timeout = 300.0;
private $username;
private $password;
private $publicKey;
private $privateKey;
private $conduitToken;
private $oauthToken;
private $capabilities = array();
const AUTH_ASYMMETRIC = 'asymmetric';
const SIGNATURE_CONSIGN_1 = 'Consign1.0/';
public function getConnectionID() {
return $this->connectionID;
}
public function __construct($uri) {
$this->uri = new PhutilURI($uri);
if (!strlen($this->uri->getDomain())) {
throw new Exception(
pht("Conduit URI '%s' must include a valid host.", $uri));
}
$this->host = $this->uri->getDomain();
}
/**
* Override the domain specified in the service URI and provide a specific
* host identity.
*
* This can be used to connect to a specific node in a cluster environment.
*/
public function setHost($host) {
$this->host = $host;
return $this;
}
public function getHost() {
return $this->host;
}
public function setConduitToken($conduit_token) {
$this->conduitToken = $conduit_token;
return $this;
}
public function getConduitToken() {
return $this->conduitToken;
}
public function setOAuthToken($oauth_token) {
$this->oauthToken = $oauth_token;
return $this;
}
public function callMethodSynchronous($method, array $params) {
return $this->callMethod($method, $params)->resolve();
}
public function didReceiveResponse($method, $data) {
if ($method == 'conduit.connect') {
$this->sessionKey = idx($data, 'sessionKey');
$this->connectionID = idx($data, 'connectionID');
}
return $data;
}
public function setTimeout($timeout) {
$this->timeout = $timeout;
return $this;
}
public function setSigningKeys(
$public_key,
PhutilOpaqueEnvelope $private_key) {
$this->publicKey = $public_key;
$this->privateKey = $private_key;
return $this;
}
public function enableCapabilities(array $capabilities) {
$this->capabilities += array_fuse($capabilities);
return $this;
}
public function callMethod($method, array $params) {
$meta = array();
if ($this->sessionKey) {
$meta['sessionKey'] = $this->sessionKey;
}
if ($this->connectionID) {
$meta['connectionID'] = $this->connectionID;
}
if ($method == 'conduit.connect') {
$certificate = idx($params, 'certificate');
if ($certificate) {
$token = time();
$params['authToken'] = $token;
$params['authSignature'] = sha1($token.$certificate);
}
unset($params['certificate']);
}
if ($this->privateKey && $this->publicKey) {
$meta['auth.type'] = self::AUTH_ASYMMETRIC;
$meta['auth.key'] = $this->publicKey;
$meta['auth.host'] = $this->getHostStringForSignature();
$signature = $this->signRequest($method, $params, $meta);
$meta['auth.signature'] = $signature;
}
if ($this->conduitToken) {
$meta['token'] = $this->conduitToken;
}
if ($this->oauthToken) {
$meta['access_token'] = $this->oauthToken;
}
if ($meta) {
$params['__conduit__'] = $meta;
}
$uri = id(clone $this->uri)->setPath('/api/'.$method);
$data = array(
'params' => json_encode($params),
'output' => 'json',
// This is a hint to Phabricator that the client expects a Conduit
// response. It is not necessary, but provides better error messages in
// some cases.
'__conduit__' => true,
);
// Always use the cURL-based HTTPSFuture, for proxy support and other
// protocol edge cases that HTTPFuture does not support.
$core_future = new HTTPSFuture($uri);
$core_future->addHeader('Host', $this->getHostStringForHeader());
$core_future->setMethod('POST');
$core_future->setTimeout($this->timeout);
// See T13507. If possible, try to compress requests. To compress requests,
// we must have "gzencode()" available and the server needs to have
// asserted it has the "gzip" capability.
$can_gzip =
(function_exists('gzencode')) &&
(isset($this->capabilities['gzip']));
if ($can_gzip) {
$gzip_data = phutil_build_http_querystring($data);
$gzip_data = gzencode($gzip_data);
$core_future->addHeader('Content-Encoding', 'gzip');
$core_future->setData($gzip_data);
} else {
$core_future->setData($data);
}
if ($this->username !== null) {
$core_future->setHTTPBasicAuthCredentials(
$this->username,
$this->password);
}
return id(new ConduitFuture($core_future))
->setClient($this, $method);
}
public function setBasicAuthCredentials($username, $password) {
$this->username = $username;
$this->password = new PhutilOpaqueEnvelope($password);
return $this;
}
private function getHostStringForHeader() {
return $this->newHostString(false);
}
private function getHostStringForSignature() {
return $this->newHostString(true);
}
/**
* Build a string describing the host for this request.
*
* This method builds strings in two modes: with explicit ports for request
* signing (which always include the port number) and with implicit ports
* for use in the "Host:" header of requests (which omit the port number if
* the port is the same as the default port for the protocol).
*
* This implicit port behavior is similar to what browsers do, so it is less
* likely to get us into trouble with webserver configurations.
*
- * @param bool True to include the port explicitly.
+ * @param bool $with_explicit_port True to include the port explicitly.
* @return string String describing the host for the request.
*/
private function newHostString($with_explicit_port) {
$host = $this->getHost();
$uri = new PhutilURI($this->uri);
$protocol = $uri->getProtocol();
$port = $uri->getPort();
$implicit_ports = array(
'https' => 443,
);
$default_port = 80;
$implicit_port = idx($implicit_ports, $protocol, $default_port);
if ($with_explicit_port) {
if (!$port) {
$port = $implicit_port;
}
} else {
if ($port == $implicit_port) {
$port = null;
}
}
if (!$port) {
$result = $host;
} else {
$result = $host.':'.$port;
}
return $result;
}
private function signRequest(
$method,
array $params,
array $meta) {
$input = self::encodeRequestDataForSignature(
$method,
$params,
$meta);
$signature = null;
$result = openssl_sign(
$input,
$signature,
$this->privateKey->openEnvelope());
if (!$result) {
throw new Exception(
pht('Unable to sign Conduit request with signing key.'));
}
return self::SIGNATURE_CONSIGN_1.base64_encode($signature);
}
public static function verifySignature(
$method,
array $params,
array $meta,
$openssl_public_key) {
$auth_type = idx($meta, 'auth.type');
switch ($auth_type) {
case self::AUTH_ASYMMETRIC:
break;
default:
throw new Exception(
pht(
'Unable to verify request signature, specified "%s" '.
'("%s") is unknown.',
'auth.type',
$auth_type));
}
$public_key = idx($meta, 'auth.key');
if (!strlen($public_key)) {
throw new Exception(
pht(
'Unable to verify request signature, no "%s" present in '.
'request protocol information.',
'auth.key'));
}
$signature = idx($meta, 'auth.signature');
if (!strlen($signature)) {
throw new Exception(
pht(
'Unable to verify request signature, no "%s" present '.
'in request protocol information.',
'auth.signature'));
}
$prefix = self::SIGNATURE_CONSIGN_1;
if (strncmp($signature, $prefix, strlen($prefix)) !== 0) {
throw new Exception(
pht(
'Unable to verify request signature, signature format is not '.
'known.'));
}
$signature = substr($signature, strlen($prefix));
$input = self::encodeRequestDataForSignature(
$method,
$params,
$meta);
$signature = base64_decode($signature);
$trap = new PhutilErrorTrap();
$result = @openssl_verify(
$input,
$signature,
$openssl_public_key);
$err = $trap->getErrorsAsString();
$trap->destroy();
if ($result === 1) {
// Signature is good.
return true;
} else if ($result === 0) {
// Signature is bad.
throw new Exception(
pht(
'Request signature verification failed: signature is not correct.'));
} else {
// Some kind of error.
if (strlen($err)) {
throw new Exception(
pht(
'OpenSSL encountered an error verifying the request signature: %s',
$err));
} else {
throw new Exception(
pht(
'OpenSSL encountered an unknown error verifying the request: %s',
$err));
}
}
}
private static function encodeRequestDataForSignature(
$method,
array $params,
array $meta) {
unset($meta['auth.signature']);
$structure = array(
'method' => $method,
'protocol' => $meta,
'parameters' => $params,
);
return self::encodeRawDataForSignature($structure);
}
public static function encodeRawDataForSignature($data) {
$out = array();
if (is_array($data)) {
if (phutil_is_natural_list($data)) {
$out[] = 'A';
$out[] = count($data);
$out[] = ':';
foreach ($data as $value) {
$out[] = self::encodeRawDataForSignature($value);
}
} else {
ksort($data);
$out[] = 'O';
$out[] = count($data);
$out[] = ':';
foreach ($data as $key => $value) {
$out[] = self::encodeRawDataForSignature($key);
$out[] = self::encodeRawDataForSignature($value);
}
}
} else if (is_string($data)) {
$out[] = 'S';
$out[] = strlen($data);
$out[] = ':';
$out[] = $data;
} else if (is_int($data)) {
$out[] = 'I';
$out[] = strlen((string)$data);
$out[] = ':';
$out[] = (string)$data;
} else if (is_null($data)) {
$out[] = 'N';
$out[] = ':';
} else if ($data === true) {
$out[] = 'B1:';
} else if ($data === false) {
$out[] = 'B0:';
} else {
throw new Exception(
pht(
'Unexpected data type in request data: %s.',
gettype($data)));
}
return implode('', $out);
}
}
diff --git a/src/configuration/ArcanistConfigurationManager.php b/src/configuration/ArcanistConfigurationManager.php
index 68ebde4e..36c05b20 100644
--- a/src/configuration/ArcanistConfigurationManager.php
+++ b/src/configuration/ArcanistConfigurationManager.php
@@ -1,362 +1,362 @@
<?php
/**
* This class holds everything related to configuration and configuration files.
*/
final class ArcanistConfigurationManager extends Phobject {
private $runtimeConfig = array();
private $workingCopy = null;
private $customArcrcFilename = null;
private $userConfigCache = null;
public function setWorkingCopyIdentity(
ArcanistWorkingCopyIdentity $working_copy) {
$this->workingCopy = $working_copy;
return $this;
}
/* -( Get config )--------------------------------------------------------- */
const CONFIG_SOURCE_RUNTIME = 'runtime';
const CONFIG_SOURCE_LOCAL = 'local';
const CONFIG_SOURCE_PROJECT = 'project';
const CONFIG_SOURCE_USER = 'user';
const CONFIG_SOURCE_SYSTEM = 'system';
const CONFIG_SOURCE_DEFAULT = 'default';
public function getProjectConfig($key) {
if ($this->workingCopy) {
return $this->workingCopy->getProjectConfig($key);
}
return null;
}
public function getLocalConfig($key) {
if ($this->workingCopy) {
return $this->workingCopy->getLocalConfig($key);
}
return null;
}
public function getWorkingCopyIdentity() {
return $this->workingCopy;
}
/**
* Read a configuration directive from any available configuration source.
* This includes the directive in local, user and system configuration in
* addition to project configuration, and configuration provided as command
* arguments ("runtime").
* The precedence is runtime > local > project > user > system
*
- * @param key Key to read.
- * @param wild Default value if key is not found.
+ * @param key $key Key to read.
+ * @param wild $default (optional) Default value if key is not found.
* @return wild Value, or default value if not found.
*
* @task config
*/
public function getConfigFromAnySource($key, $default = null) {
$all = $this->getConfigFromAllSources($key);
return empty($all) ? $default : head($all);
}
/**
* For the advanced case where you want customized configuration handling.
*
* Reads the configuration from all available sources, returning a map (array)
* of results, with the source as key. Missing values will not be in the map,
* so an empty array will be returned if no results are found.
*
* The map is ordered by the canonical sources precedence, which is:
* runtime > local > project > user > system
*
- * @param key Key to read
+ * @param key $key Key to read
* @return array Mapping of source => value read. Sources with no value are
* not in the array.
*
* @task config
*/
public function getConfigFromAllSources($key) {
$results = array();
$settings = new ArcanistSettings();
$pval = idx($this->runtimeConfig, $key);
if ($pval !== null) {
$results[self::CONFIG_SOURCE_RUNTIME] =
$settings->willReadValue($key, $pval);
}
$pval = $this->getLocalConfig($key);
if ($pval !== null) {
$results[self::CONFIG_SOURCE_LOCAL] =
$settings->willReadValue($key, $pval);
}
$pval = $this->getProjectConfig($key);
if ($pval !== null) {
$results[self::CONFIG_SOURCE_PROJECT] =
$settings->willReadValue($key, $pval);
}
$user_config = $this->readUserArcConfig();
// For "aliases" coming from the user config file specifically, read the
// top level "aliases" key instead of the "aliases" key inside the "config"
// setting. Aliases were originally user-specific but later became standard
// configuration, which is why this works oddly.
if ($key === 'aliases') {
$pval = idx($this->readUserConfigurationFile(), $key);
} else {
$pval = idx($user_config, $key);
}
if ($pval !== null) {
$results[self::CONFIG_SOURCE_USER] =
$settings->willReadValue($key, $pval);
}
$system_config = $this->readSystemArcConfig();
$pval = idx($system_config, $key);
if ($pval !== null) {
$results[self::CONFIG_SOURCE_SYSTEM] =
$settings->willReadValue($key, $pval);
}
$default_config = $this->readDefaultConfig();
if (array_key_exists($key, $default_config)) {
$results[self::CONFIG_SOURCE_DEFAULT] = $default_config[$key];
}
return $results;
}
/**
* Sets a runtime config value that takes precedence over any static
* config values.
*
- * @param key Key to set.
- * @param value The value of the key.
+ * @param key $key Key to set.
+ * @param value $value The value of the key.
*
* @task config
*/
public function setRuntimeConfig($key, $value) {
$this->runtimeConfig[$key] = $value;
return $this;
}
/* -( Read/write config )--------------------------------------------------- */
public function readLocalArcConfig() {
if ($this->workingCopy) {
return $this->workingCopy->readLocalArcConfig();
}
return array();
}
public function writeLocalArcConfig(array $config) {
if ($this->workingCopy) {
return $this->workingCopy->writeLocalArcConfig($config);
}
throw new Exception(pht('No working copy to write config to!'));
}
/**
* This is probably not the method you're looking for; try
* @{method:readUserArcConfig}.
*/
public function readUserConfigurationFile() {
if ($this->userConfigCache === null) {
$user_config = array();
$user_config_path = $this->getUserConfigurationFileLocation();
$console = PhutilConsole::getConsole();
if (Filesystem::pathExists($user_config_path)) {
$console->writeLog(
"%s\n",
pht(
'Config: Reading user configuration file "%s"...',
$user_config_path));
if (!phutil_is_windows()) {
$mode = fileperms($user_config_path);
if (!$mode) {
throw new Exception(
pht(
'Unable to read file permissions for "%s"!',
$user_config_path));
}
if ($mode & 0177) {
// Mode should allow only owner access.
$prompt = pht(
"File permissions on your %s are too open. ".
"Fix them by chmod'ing to 600?",
'~/.arcrc');
if (!phutil_console_confirm($prompt, $default_no = false)) {
throw new ArcanistUsageException(
pht('Set %s to file mode 600.', '~/.arcrc'));
}
execx('chmod 600 %s', $user_config_path);
// Drop the stat cache so we don't read the old permissions if
// we end up here again. If we don't do this, we may prompt the user
// to fix permissions multiple times.
clearstatcache();
}
}
$user_config_data = Filesystem::readFile($user_config_path);
try {
$user_config = phutil_json_decode($user_config_data);
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht("Your '%s' file is not a valid JSON file.", '~/.arcrc'),
$ex);
}
} else {
$console->writeLog(
"%s\n",
pht(
'Config: Did not find user configuration at "%s".',
$user_config_path));
}
$this->userConfigCache = $user_config;
}
return $this->userConfigCache;
}
/**
* This is probably not the method you're looking for; try
* @{method:writeUserArcConfig}.
*/
public function writeUserConfigurationFile($config) {
$json_encoder = new PhutilJSON();
$json = $json_encoder->encodeFormatted($config);
$path = $this->getUserConfigurationFileLocation();
Filesystem::writeFile($path, $json);
if (!phutil_is_windows()) {
execx('chmod 600 %s', $path);
}
}
public function setUserConfigurationFileLocation($custom_arcrc) {
if (!Filesystem::pathExists($custom_arcrc)) {
throw new Exception(
pht('Custom %s file was specified, but it was not found!', 'arcrc'));
}
$this->customArcrcFilename = $custom_arcrc;
$this->userConfigCache = null;
return $this;
}
public function getUserConfigurationFileLocation() {
if ($this->customArcrcFilename !== null) {
return $this->customArcrcFilename;
}
if (phutil_is_windows()) {
return getenv('APPDATA').'/.arcrc';
} else {
return getenv('HOME').'/.arcrc';
}
}
public function readUserArcConfig() {
$config = $this->readUserConfigurationFile();
if (isset($config['config'])) {
$config = $config['config'];
}
return $config;
}
public function writeUserArcConfig(array $options) {
$config = $this->readUserConfigurationFile();
$config['config'] = $options;
$this->writeUserConfigurationFile($config);
}
public function getSystemArcConfigLocation() {
if (phutil_is_windows()) {
return Filesystem::resolvePath(
'Phabricator/Arcanist/config',
getenv('ProgramData'));
} else {
return '/etc/arcconfig';
}
}
public function readSystemArcConfig() {
static $system_config;
if ($system_config === null) {
$system_config = array();
$system_config_path = $this->getSystemArcConfigLocation();
$console = PhutilConsole::getConsole();
if (Filesystem::pathExists($system_config_path)) {
$console->writeLog(
"%s\n",
pht(
'Config: Reading system configuration file "%s"...',
$system_config_path));
$file = Filesystem::readFile($system_config_path);
try {
$system_config = phutil_json_decode($file);
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht(
"Your '%s' file is not a valid JSON file.",
$system_config_path),
$ex);
}
} else {
$console->writeLog(
"%s\n",
pht(
'Config: Did not find system configuration at "%s".',
$system_config_path));
}
}
return $system_config;
}
public function applyRuntimeArcConfig($args) {
$arcanist_settings = new ArcanistSettings();
$options = $args->getArg('config');
foreach ($options as $opt) {
$opt_config = preg_split('/=/', $opt, 2);
if (count($opt_config) !== 2) {
throw new ArcanistUsageException(
pht(
"Argument was '%s', but must be '%s'. For example, %s",
$opt,
'name=value',
'history.immutable=true'));
}
list($key, $value) = $opt_config;
$value = $arcanist_settings->willWriteValue($key, $value);
$this->setRuntimeConfig($key, $value);
}
return $this->runtimeConfig;
}
public function readDefaultConfig() {
$settings = new ArcanistSettings();
return $settings->getDefaultSettings();
}
}
diff --git a/src/console/PhutilConsole.php b/src/console/PhutilConsole.php
index 5d646f7c..f6eabe71 100644
--- a/src/console/PhutilConsole.php
+++ b/src/console/PhutilConsole.php
@@ -1,295 +1,295 @@
<?php
/**
* Provides access to the command-line console. Instead of reading from or
* writing to stdin/stdout/stderr directly, this class provides a richer API
* including support for ANSI color and formatting, convenience methods for
* prompting the user, and the ability to interact with stdin/stdout/stderr
* in some other process instead of this one.
*
* @task construct Construction
* @task interface Interfacing with the User
* @task internal Internals
*/
final class PhutilConsole extends Phobject {
private static $console;
private $server;
private $channel;
private $messages = array();
private $flushing = false;
private $disabledTypes;
/* -( Console Construction )----------------------------------------------- */
/**
* Use @{method:newLocalConsole} or @{method:newRemoteConsole} to construct
* new consoles.
*
* @task construct
*/
private function __construct() {
$this->disabledTypes = new PhutilArrayWithDefaultValue();
}
/**
* Get the current console. If there's no active console, a new local console
* is created (see @{method:newLocalConsole} for details). You can change the
* active console with @{method:setConsole}.
*
* @return PhutilConsole Active console.
* @task construct
*/
public static function getConsole() {
if (empty(self::$console)) {
self::setConsole(self::newLocalConsole());
}
return self::$console;
}
/**
* Set the active console.
*
- * @param PhutilConsole
+ * @param PhutilConsole $console
* @return void
* @task construct
*/
public static function setConsole(PhutilConsole $console) {
self::$console = $console;
}
/**
* Create a new console attached to stdin/stdout/stderr of this process.
* This is how consoles normally work -- for instance, writing output with
* @{method:writeOut} prints directly to stdout. If you don't create a
* console explicitly, a new local console is created for you.
*
* @return PhutilConsole A new console which operates on the pipes of this
* process.
* @task construct
*/
public static function newLocalConsole() {
return self::newConsoleForServer(new PhutilConsoleServer());
}
public static function newConsoleForServer(PhutilConsoleServer $server) {
$console = new PhutilConsole();
$console->server = $server;
return $console;
}
public static function newRemoteConsole() {
$io_channel = new PhutilSocketChannel(
fopen('php://stdin', 'r'),
fopen('php://stdout', 'w'));
$protocol_channel = new PhutilPHPObjectProtocolChannel($io_channel);
$console = new PhutilConsole();
$console->channel = $protocol_channel;
return $console;
}
/* -( Interfacing with the User )------------------------------------------ */
public function confirm($prompt, $default = false) {
$message = id(new PhutilConsoleMessage())
->setType(PhutilConsoleMessage::TYPE_CONFIRM)
->setData(
array(
'prompt' => $prompt,
'default' => $default,
));
$this->writeMessage($message);
$response = $this->waitForMessage();
return $response->getData();
}
public function prompt($prompt, $history = '') {
$message = id(new PhutilConsoleMessage())
->setType(PhutilConsoleMessage::TYPE_PROMPT)
->setData(
array(
'prompt' => $prompt,
'history' => $history,
));
$this->writeMessage($message);
$response = $this->waitForMessage();
return $response->getData();
}
public function sendMessage($data) {
$message = id(new PhutilConsoleMessage())->setData($data);
return $this->writeMessage($message);
}
public function writeOut($pattern /* , ... */) {
$args = func_get_args();
return $this->writeTextMessage(PhutilConsoleMessage::TYPE_OUT, $args);
}
public function writeErr($pattern /* , ... */) {
$args = func_get_args();
return $this->writeTextMessage(PhutilConsoleMessage::TYPE_ERR, $args);
}
public function writeLog($pattern /* , ... */) {
$args = func_get_args();
return $this->writeTextMessage(PhutilConsoleMessage::TYPE_LOG, $args);
}
public function beginRedirectOut() {
// We need as small buffer as possible. 0 means infinite, 1 means 4096 in
// PHP < 5.4.0.
ob_start(array($this, 'redirectOutCallback'), 2);
$this->flushing = true;
}
public function endRedirectOut() {
$this->flushing = false;
ob_end_flush();
}
/* -( Internals )---------------------------------------------------------- */
// Must be public because it is called from output buffering.
public function redirectOutCallback($string) {
if (strlen($string)) {
$this->flushing = false;
$this->writeOut('%s', $string);
$this->flushing = true;
}
return '';
}
private function writeTextMessage($type, array $argv) {
$message = id(new PhutilConsoleMessage())
->setType($type)
->setData($argv);
$this->writeMessage($message);
return $this;
}
private function writeMessage(PhutilConsoleMessage $message) {
if ($this->disabledTypes[$message->getType()]) {
return $this;
}
if ($this->flushing) {
ob_flush();
}
if ($this->channel) {
$this->channel->write($message);
$this->channel->flush();
} else {
$response = $this->server->handleMessage($message);
if ($response) {
$this->messages[] = $response;
}
}
return $this;
}
private function waitForMessage() {
if ($this->channel) {
$message = $this->channel->waitForMessage();
} else if ($this->messages) {
$message = array_shift($this->messages);
} else {
throw new Exception(
pht(
'%s called with no messages!',
__FUNCTION__.'()'));
}
return $message;
}
public function getServer() {
return $this->server;
}
private function disableMessageType($type) {
$this->disabledTypes[$type] += 1;
return $this;
}
private function enableMessageType($type) {
if ($this->disabledTypes[$type] == 0) {
throw new Exception(pht("Message type '%s' is already enabled!", $type));
}
$this->disabledTypes[$type] -= 1;
return $this;
}
public function disableOut() {
return $this->disableMessageType(PhutilConsoleMessage::TYPE_OUT);
}
public function enableOut() {
return $this->enableMessageType(PhutilConsoleMessage::TYPE_OUT);
}
public function isLogEnabled() {
$message = id(new PhutilConsoleMessage())
->setType(PhutilConsoleMessage::TYPE_ENABLED)
->setData(
array(
'which' => PhutilConsoleMessage::TYPE_LOG,
));
$this->writeMessage($message);
$response = $this->waitForMessage();
return $response->getData();
}
public function isErrATTY() {
$message = id(new PhutilConsoleMessage())
->setType(PhutilConsoleMessage::TYPE_TTY)
->setData(
array(
'which' => PhutilConsoleMessage::TYPE_ERR,
));
$this->writeMessage($message);
$response = $this->waitForMessage();
return $response->getData();
}
public function getErrCols() {
$message = id(new PhutilConsoleMessage())
->setType(PhutilConsoleMessage::TYPE_COLS)
->setData(
array(
'which' => PhutilConsoleMessage::TYPE_ERR,
));
$this->writeMessage($message);
$response = $this->waitForMessage();
return $response->getData();
}
}
diff --git a/src/console/PhutilInteractiveEditor.php b/src/console/PhutilInteractiveEditor.php
index 3e12611c..eb3c4803 100644
--- a/src/console/PhutilInteractiveEditor.php
+++ b/src/console/PhutilInteractiveEditor.php
@@ -1,349 +1,349 @@
<?php
/**
* Edit a document interactively, by launching $EDITOR (like vi or nano).
*
* $result = id(new InteractiveEditor($document))
* ->setName('shopping_list')
* ->setLineOffset(15)
* ->editInteractively();
*
* This will launch the user's $EDITOR to edit the specified '$document', and
* return their changes into '$result'.
*
* @task create Creating a New Editor
* @task edit Editing Interactively
* @task config Configuring Options
*/
final class PhutilInteractiveEditor extends Phobject {
private $name = '';
private $content = '';
private $offset = 0;
private $preferred;
private $fallback;
private $taskMessage;
/* -( Creating a New Editor )---------------------------------------------- */
/**
* Constructs an interactive editor, using the text of a document.
*
- * @param string Document text.
+ * @param string $content Document text.
* @return $this
*
* @task create
*/
public function __construct($content) {
$this->setContent($content);
}
/* -( Editing Interactively )----------------------------------------------- */
/**
* Launch an editor and edit the content. The edited content will be
* returned.
*
* @return string Edited content.
* @throws Exception The editor exited abnormally or something untoward
* occurred.
*
* @task edit
*/
public function editInteractively() {
$name = $this->getName();
$content = $this->getContent();
if (phutil_is_windows()) {
$content = str_replace("\n", "\r\n", $content);
}
$tmp = Filesystem::createTemporaryDirectory('edit.');
$path = $tmp.DIRECTORY_SEPARATOR.$name;
try {
Filesystem::writeFile($path, $content);
} catch (Exception $ex) {
Filesystem::remove($tmp);
throw $ex;
}
$editor = $this->getEditor();
$offset = $this->getLineOffset();
$binary = basename($editor);
// This message is primarily an assistance to users with GUI-based
// editors configured. Users with terminal-based editors won't have a
// chance to see this prior to the editor being launched.
echo tsprintf(
"%s\n",
pht('Launching editor "%s"...', $binary));
$task_message = $this->getTaskMessage();
if ($task_message !== null) {
echo tsprintf("%s\n", $task_message);
}
$err = $this->invokeEditor($editor, $path, $offset);
if ($err) {
// See T13297. On macOS, "vi" and "vim" may exit with errors even though
// the edit succeeded. If the binary is "vi" or "vim" and we get an exit
// code, we perform an additional test on the binary.
$vi_binaries = array(
'vi' => true,
'vim' => true,
);
if (isset($vi_binaries[$binary])) {
// This runs "Q" (an invalid command), then "q" (a valid command,
// meaning "quit"). Vim binaries with behavior that makes them poor
// interactive editors will exit "1".
list($diagnostic_err) = exec_manual('%R +Q +q', $binary);
// If we get an error back, the binary is badly behaved. Ignore the
// original error and assume it's not meaningful, since it just
// indicates the user made a typo in a command when editing
// interactively, which is routine and unconcerning.
if ($diagnostic_err) {
$err = 0;
}
}
}
if ($err) {
Filesystem::remove($tmp);
throw new Exception(pht('Editor exited with an error code (#%d).', $err));
}
try {
$result = Filesystem::readFile($path);
Filesystem::remove($tmp);
} catch (Exception $ex) {
Filesystem::remove($tmp);
throw $ex;
}
if (phutil_is_windows()) {
$result = str_replace("\r\n", "\n", $result);
}
$this->setContent($result);
return $this->getContent();
}
private function invokeEditor($editor, $path, $offset) {
// NOTE: Popular Windows editors like Notepad++ and GitPad do not support
// line offsets, so just ignore the offset feature on Windows. We rarely
// use it anyway.
$offset_flag = '';
if ($offset && !phutil_is_windows()) {
$offset = (int)$offset;
if (preg_match('/^mate/', $editor)) {
$offset_flag = csprintf('-l %d', $offset);
} else {
$offset_flag = csprintf('+%d', $offset);
}
}
$cmd = csprintf(
'%C %C %s',
$editor,
$offset_flag,
$path);
return phutil_passthru('%C', $cmd);
}
/* -( Configuring Options )------------------------------------------------- */
/**
* Set the line offset where the cursor should be positioned when the editor
* opens. By default, the cursor will be positioned at the start of the
* content.
*
- * @param int Line number where the cursor should be positioned.
+ * @param int $offset Line number where the cursor should be positioned.
* @return $this
*
* @task config
*/
public function setLineOffset($offset) {
$this->offset = (int)$offset;
return $this;
}
/**
* Get the current line offset. See setLineOffset().
*
* @return int Current line offset.
*
* @task config
*/
public function getLineOffset() {
return $this->offset;
}
/**
* Set the document name. Depending on the editor, this may be exposed to
* the user and can give them a sense of what they're editing.
*
- * @param string Document name.
+ * @param string $name Document name.
* @return $this
*
* @task config
*/
public function setName($name) {
$name = preg_replace('/[^A-Z0-9._-]+/i', '', $name);
$this->name = $name;
return $this;
}
/**
* Get the current document name. See @{method:setName} for details.
*
* @return string Current document name.
*
* @task config
*/
public function getName() {
if (!strlen($this->name)) {
return 'untitled';
}
return $this->name;
}
/**
* Set the text content to be edited.
*
- * @param string New content.
+ * @param string $content New content.
* @return $this
*
* @task config
*/
public function setContent($content) {
$this->content = $content;
return $this;
}
/**
* Retrieve the current content.
*
* @return string
*
* @task config
*/
public function getContent() {
return $this->content;
}
/**
* Set the fallback editor program to be used if the env variable $EDITOR
* is not available and there is no `editor` binary in PATH.
*
- * @param string Command-line editing program (e.g. 'emacs', 'vi')
+ * @param string $editor Command-line editing program (e.g. 'emacs', 'vi')
* @return $this
*
* @task config
*/
public function setFallbackEditor($editor) {
$this->fallback = $editor;
return $this;
}
/**
* Set the preferred editor program. If set, this will override all other
* sources of editor configuration, like $EDITOR.
*
- * @param string Command-line editing program (e.g. 'emacs', 'vi')
+ * @param string $editor Command-line editing program (e.g. 'emacs', 'vi')
* @return $this
*
* @task config
*/
public function setPreferredEditor($editor) {
$this->preferred = $editor;
return $this;
}
/**
* Set the message that identifies the task for which the editor is being
* launched, displayed to the user prior to it being launched.
*
- * @param string The message to display to the user.
+ * @param string $task_message The message to display to the user.
* @return $this
*
* @task config
*/
public function setTaskMessage($task_message) {
$this->taskMessage = $task_message;
return $this;
}
/**
* Retrieve the current message that will display to the user just prior to
* invoking the editor.
*
* @return string The message that will display to the user, or null if no
* message will be displayed.
*
* @task config
*/
public function getTaskMessage() {
return $this->taskMessage;
}
/**
* Get the name of the editor program to use. The value of the environmental
* variable $EDITOR will be used if available; otherwise, the `editor` binary
* if present; otherwise the best editor will be selected.
*
* @return string Command-line editing program.
*
* @task config
*/
public function getEditor() {
if ($this->preferred) {
return $this->preferred;
}
$editor = getenv('EDITOR');
if ($editor) {
return $editor;
}
if ($this->fallback) {
return $this->fallback;
}
$candidates = array('editor', 'nano', 'sensible-editor', 'vi');
foreach ($candidates as $cmd) {
if (Filesystem::binaryExists($cmd)) {
return $cmd;
}
}
throw new Exception(
pht(
'Unable to launch an interactive text editor. Set the %s '.
'environment variable to an appropriate editor.',
'EDITOR'));
}
}
diff --git a/src/console/format.php b/src/console/format.php
index a4fa98d5..7e621a92 100644
--- a/src/console/format.php
+++ b/src/console/format.php
@@ -1,209 +1,209 @@
<?php
function phutil_console_format($format /* ... */) {
$args = func_get_args();
return call_user_func_array(
array('PhutilConsoleFormatter', 'formatString'),
$args);
}
function phutil_console_confirm($prompt, $default_no = true) {
$prompt_options = $default_no ? '[y/N]' : '[Y/n]';
do {
$response = phutil_console_prompt($prompt.' '.$prompt_options);
$c = trim(strtolower($response));
} while ($c != 'y' && $c != 'n' && $c != '');
echo "\n";
if ($default_no) {
return ($c == 'y');
} else {
return ($c != 'n');
}
}
function phutil_console_select($prompt, $min, $max) {
$select_options = '['.$min.' - '.$max.']';
do {
$response = phutil_console_prompt($prompt.' '.$select_options);
$selection = trim($response);
if (preg_match('/^\d+\z/', $selection)) {
$selection = (int)$selection;
if ($selection >= $min && $selection <= $max) {
return $selection;
}
}
} while (true);
}
function phutil_console_prompt($prompt, $history = '') {
echo "\n\n";
$prompt = phutil_console_wrap($prompt.' ', 4);
try {
phutil_console_require_tty();
} catch (PhutilConsoleStdinNotInteractiveException $ex) {
// Throw after echoing the prompt so the user has some idea what happened.
echo $prompt;
throw $ex;
}
// `escapeshellarg` makes double quotes in the command below disappear on
// Windows, which breaks prompts when using history. See T6348
$use_history = !phutil_is_windows();
if ($history == '') {
$use_history = false;
} else {
// Test if bash is available by seeing if it can run `true`.
list($err) = exec_manual('bash -c %s', 'true');
if ($err) {
$use_history = false;
}
}
if (!$use_history) {
echo $prompt;
$response = fgets(STDIN);
} else {
// There's around 0% chance that readline() is available directly in PHP,
// so we're using bash/read/history instead.
$command = csprintf(
'bash -c %s',
csprintf(
'history -r %s 2>/dev/null; '.
'read -e -p %s; '.
'echo "$REPLY"; '.
'history -s "$REPLY" 2>/dev/null; '.
'history -w %s 2>/dev/null',
$history,
$prompt,
$history));
// execx() doesn't work with input, phutil_passthru() doesn't return output.
$response = shell_exec($command);
}
return rtrim($response, "\r\n");
}
/**
* Soft wrap text for display on a console, respecting UTF8 character boundaries
* and ANSI color escape sequences.
*
- * @param string Text to wrap.
- * @param int Optional indent level.
- * @param bool True to also indent the first line.
+ * @param string $text Text to wrap.
+ * @param int $indent (optional) Indent level. Defaults to 0.
+ * @param bool $with_prefix (Optional) True to also indent the first line.
* @return string Wrapped text.
*/
function phutil_console_wrap($text, $indent = 0, $with_prefix = true) {
$lines = array();
$width = (78 - $indent);
$esc = chr(27);
$break_pos = null;
$len_after_break = 0;
$line_len = 0;
$line = array();
$lines = array();
$vector = phutil_utf8v($text);
$vector_len = count($vector);
for ($ii = 0; $ii < $vector_len; $ii++) {
$chr = $vector[$ii];
// If this is an ANSI escape sequence for a color code, just consume it
// without counting it toward the character limit. This prevents lines
// with bold/color on them from wrapping too early.
if ($chr == $esc) {
for ($ii; $ii < $vector_len; $ii++) {
$line[] = $vector[$ii];
if ($vector[$ii] == 'm') {
break;
}
}
continue;
}
$line[] = $chr;
++$line_len;
++$len_after_break;
if ($line_len > $width) {
if ($break_pos !== null) {
$slice = array_slice($line, 0, $break_pos);
while (count($slice) && end($slice) == ' ') {
array_pop($slice);
}
$slice[] = "\n";
$lines[] = $slice;
$line = array_slice($line, $break_pos);
$line_len = $len_after_break;
$len_after_break = 0;
$break_pos = null;
}
}
if ($chr == ' ') {
$break_pos = count($line);
$len_after_break = 0;
}
if ($chr == "\n") {
$lines[] = $line;
$line = array();
$len_after_break = 0;
$line_len = 0;
$break_pos = null;
}
}
if ($line) {
if ($line) {
$lines[] = $line;
}
}
$pre = null;
if ($indent) {
$pre = str_repeat(' ', $indent);
}
foreach ($lines as $idx => $line) {
if ($idx == 0 && !$with_prefix) {
$prefix = null;
} else {
$prefix = $pre;
}
$lines[$idx] = $prefix.implode('', $line);
}
return implode('', $lines);
}
function phutil_console_require_tty() {
if (function_exists('posix_isatty') && !posix_isatty(STDIN)) {
throw new PhutilConsoleStdinNotInteractiveException();
}
}
/**
* Determine the width of the terminal, if possible. Returns `null` on failure.
*
* @return int|null Terminal width in characters, or null on failure.
*/
function phutil_console_get_terminal_width() {
return PhutilConsoleMetrics::getDefaultConsole()
->getTerminalWidth();
}
diff --git a/src/console/view/PhutilConsoleTable.php b/src/console/view/PhutilConsoleTable.php
index 392ff813..86e9f4e3 100644
--- a/src/console/view/PhutilConsoleTable.php
+++ b/src/console/view/PhutilConsoleTable.php
@@ -1,306 +1,306 @@
<?php
/**
* Show a table in the console. Usage:
*
* $table = id(new PhutilConsoleTable())
* ->addColumn('id', array('title' => 'ID', 'align' => 'right'))
* ->addColumn('name', array('title' => 'Username', 'align' => 'center'))
* ->addColumn('email', array('title' => 'Email Address'))
*
* ->addRow(array(
* 'id' => 12345,
* 'name' => 'alicoln',
* 'email' => 'abraham@lincoln.com',
* ))
* ->addRow(array(
* 'id' => 99999999,
* 'name' => 'jbloggs',
* 'email' => 'joe@bloggs.com',
* ))
*
* ->setBorders(true)
* ->draw();
*/
final class PhutilConsoleTable extends PhutilConsoleView {
private $columns = array();
private $data = array();
private $widths = array();
private $borders = false;
private $padding = 1;
private $showHeader = true;
const ALIGN_LEFT = 'left';
const ALIGN_CENTER = 'center';
const ALIGN_RIGHT = 'right';
/* -( Configuration )------------------------------------------------------ */
public function setBorders($borders) {
$this->borders = $borders;
return $this;
}
public function setPadding($padding) {
$this->padding = $padding;
return $this;
}
public function setShowHeader($show_header) {
$this->showHeader = $show_header;
return $this;
}
/* -( Data )--------------------------------------------------------------- */
public function addColumn($key, array $column = array()) {
PhutilTypeSpec::checkMap($column, array(
'title' => 'optional string',
'align' => 'optional string',
));
$this->columns[$key] = $column;
return $this;
}
public function addColumns(array $columns) {
foreach ($columns as $key => $column) {
$this->addColumn($key, $column);
}
return $this;
}
public function addRow(array $data) {
$this->data[] = $data;
foreach ($data as $key => $value) {
$this->widths[$key] = max(
idx($this->widths, $key, 0),
phutil_utf8_console_strlen($value));
}
return $this;
}
public function drawRows(array $rows) {
$this->data = array();
$this->widths = array();
foreach ($rows as $row) {
$this->addRow($row);
}
return $this->draw();
}
/* -( Drawing )------------------------------------------------------------ */
protected function drawView() {
return $this->drawLines(
array_merge(
$this->getHeader(),
$this->getBody(),
$this->getFooter()));
}
private function getHeader() {
$output = array();
if ($this->borders) {
$output[] = $this->formatSeparator('=');
}
if (!$this->showHeader) {
return $output;
}
$columns = array();
foreach ($this->columns as $key => $column) {
$title = tsprintf('**%s**', $column['title']);
if ($this->shouldAddSpacing($key, $column)) {
$title = $this->alignString(
$title,
$this->getWidth($key),
idx($column, 'align', self::ALIGN_LEFT));
}
$columns[] = $title;
}
$output[] = $this->formatRow($columns);
if ($this->borders) {
$output[] = $this->formatSeparator('=');
}
return $output;
}
private function getBody() {
$output = array();
foreach ($this->data as $data) {
$columns = array();
foreach ($this->columns as $key => $column) {
if (!$this->shouldAddSpacing($key, $column)) {
$columns[] = idx($data, $key, '');
} else {
$columns[] = $this->alignString(
idx($data, $key, ''),
$this->getWidth($key),
idx($column, 'align', self::ALIGN_LEFT));
}
}
$output[] = $this->formatRow($columns);
}
return $output;
}
private function getFooter() {
$output = array();
if ($this->borders) {
$columns = array();
foreach ($this->getColumns() as $column) {
$columns[] = str_repeat('=', $this->getWidth($column));
}
$output[] = array(
'+',
$this->implode('+', $columns),
'+',
);
}
return $output;
}
/* -( Internals )---------------------------------------------------------- */
/**
* Returns if the specified column should have spacing added.
*
* @return bool
*/
private function shouldAddSpacing($key, $column) {
if (!$this->borders) {
if (last_key($this->columns) === $key) {
if (idx($column, 'align', self::ALIGN_LEFT) === self::ALIGN_LEFT) {
// Don't add extra spaces to this column since it's the last column,
// left aligned, and we're not showing borders. This prevents
// unnecessary empty lines from appearing when the extra spaces
// wrap around the terminal.
return false;
}
}
}
return true;
}
/**
* Returns the column IDs.
*
* @return list<string>
*/
protected function getColumns() {
return array_keys($this->columns);
}
/**
* Get the width of a specific column, including padding.
*
- * @param string
+ * @param string $key
* @return int
*/
protected function getWidth($key) {
$width = max(
idx($this->widths, $key),
phutil_utf8_console_strlen(
idx(idx($this->columns, $key, array()), 'title', '')));
return $width + 2 * $this->padding;
}
protected function alignString($string, $width, $align) {
$num_padding = $width -
(2 * $this->padding) - phutil_utf8_console_strlen($string);
switch ($align) {
case self::ALIGN_LEFT:
$num_left_padding = 0;
$num_right_padding = $num_padding;
break;
case self::ALIGN_CENTER:
$num_left_padding = (int)($num_padding / 2);
$num_right_padding = $num_padding - $num_left_padding;
break;
case self::ALIGN_RIGHT:
$num_left_padding = $num_padding;
$num_right_padding = 0;
break;
}
$left_padding = str_repeat(' ', $num_left_padding);
$right_padding = str_repeat(' ', $num_right_padding);
return array(
$left_padding,
$string,
$right_padding,
);
}
/**
* Format cells into an entire row.
*
- * @param list<string>
+ * @param list<string> $columns
* @return string
*/
protected function formatRow(array $columns) {
$padding = str_repeat(' ', $this->padding);
if ($this->borders) {
$separator = $padding.'|'.$padding;
return array(
'|'.$padding,
$this->implode($separator, $columns),
$padding.'|',
);
} else {
return $this->implode($padding, $columns);
}
}
protected function formatSeparator($string) {
$columns = array();
if ($this->borders) {
$separator = '+';
} else {
$separator = '';
}
foreach ($this->getColumns() as $column) {
$columns[] = str_repeat($string, $this->getWidth($column));
}
return array(
$separator,
$this->implode($separator, $columns),
$separator,
);
}
}
diff --git a/src/console/view/PhutilConsoleView.php b/src/console/view/PhutilConsoleView.php
index 85f5b8f9..39fe58c6 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
* @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 Any drawable view.
+ * @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> List of views, one per line.
+ * @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/differential/ArcanistDifferentialCommitMessage.php b/src/differential/ArcanistDifferentialCommitMessage.php
index cada4895..27053d91 100644
--- a/src/differential/ArcanistDifferentialCommitMessage.php
+++ b/src/differential/ArcanistDifferentialCommitMessage.php
@@ -1,149 +1,149 @@
<?php
/**
* Represents a parsed commit message.
*/
final class ArcanistDifferentialCommitMessage extends Phobject {
private $rawCorpus;
private $revisionID;
private $fields = array();
private $xactions = null;
private $gitSVNBaseRevision;
private $gitSVNBasePath;
private $gitSVNUUID;
public static function newFromRawCorpus($corpus) {
$obj = new ArcanistDifferentialCommitMessage();
$obj->rawCorpus = $corpus;
$obj->revisionID = $obj->parseRevisionIDFromRawCorpus($corpus);
$pattern = '/^git-svn-id:\s*([^@]+)@(\d+)\s+(.*)$/m';
$match = null;
if (preg_match($pattern, $corpus, $match)) {
$obj->gitSVNBaseRevision = $match[1].'@'.$match[2];
$obj->gitSVNBasePath = $match[1];
$obj->gitSVNUUID = $match[3];
}
return $obj;
}
public function getRawCorpus() {
return $this->rawCorpus;
}
public function getRevisionID() {
return $this->revisionID;
}
public function getRevisionMonogram() {
if ($this->revisionID) {
return 'D'.$this->revisionID;
}
return null;
}
public function pullDataFromConduit(
ConduitClient $conduit,
$partial = false) {
$result = $conduit->callMethodSynchronous(
'differential.parsecommitmessage',
array(
'corpus' => $this->rawCorpus,
'partial' => $partial,
));
$this->fields = $result['fields'];
// NOTE: This does not exist prior to late October 2017.
$this->xactions = idx($result, 'transactions');
if (!empty($result['errors'])) {
throw new ArcanistDifferentialCommitMessageParserException(
$result['errors']);
}
return $this;
}
public function getFieldValue($key) {
if (array_key_exists($key, $this->fields)) {
return $this->fields[$key];
}
return null;
}
public function setFieldValue($key, $value) {
$this->fields[$key] = $value;
return $this;
}
public function getFields() {
return $this->fields;
}
public function getGitSVNBaseRevision() {
return $this->gitSVNBaseRevision;
}
public function getGitSVNBasePath() {
return $this->gitSVNBasePath;
}
public function getGitSVNUUID() {
return $this->gitSVNUUID;
}
public function getChecksum() {
$fields = array_filter($this->fields);
ksort($fields);
$fields = json_encode($fields);
return md5($fields);
}
public function getTransactions() {
return $this->xactions;
}
/**
* Extract the revision ID from a commit message.
*
- * @param string Raw commit message.
+ * @param string $corpus Raw commit message.
* @return int|null Revision ID, if the commit message contains one.
*/
private function parseRevisionIDFromRawCorpus($corpus) {
$match = null;
if (!preg_match('/^Differential Revision:\s*(.+)/im', $corpus, $match)) {
return null;
}
$revision_value = trim($match[1]);
$revision_pattern = '/^[dD]([1-9]\d*)\z/';
// Accept a bare revision ID like "D123".
if (preg_match($revision_pattern, $revision_value, $match)) {
return (int)$match[1];
}
// Otherwise, try to find a full URI.
$uri = new PhutilURI($revision_value);
$path = $uri->getPath();
$path = trim($path, '/');
if (preg_match($revision_pattern, $path, $match)) {
return (int)$match[1];
}
throw new ArcanistUsageException(
pht(
'Invalid "Differential Revision" field in commit message. This field '.
'should have a revision identifier like "%s" or a server URI '.
'like "%s", but has "%s".',
'D123',
'https://devtools.example.com/D123',
$revision_value));
}
}
diff --git a/src/error/PhutilErrorHandler.php b/src/error/PhutilErrorHandler.php
index 2fc8fe7d..510fe9f8 100644
--- a/src/error/PhutilErrorHandler.php
+++ b/src/error/PhutilErrorHandler.php
@@ -1,627 +1,627 @@
<?php
/**
* Improve PHP error logs and optionally route errors, exceptions and debugging
* information to a central listener.
*
* This class takes over the PHP error and exception handlers when you call
* ##PhutilErrorHandler::initialize()## and forwards all debugging information
* to a listener you install with ##PhutilErrorHandler::addErrorListener()##.
*
* To use PhutilErrorHandler, which will enhance the messages printed to the
* PHP error log, just initialize it:
*
* PhutilErrorHandler::initialize();
*
* To additionally install a custom listener which can print error information
* to some other file or console, register a listener:
*
* PhutilErrorHandler::addErrorListener($some_callback);
*
* For information on writing an error listener, see
* @{function:phutil_error_listener_example}. Providing a listener is optional,
* you will benefit from improved error logs even without one.
*
* Phabricator uses this class to drive the DarkConsole "Error Log" plugin.
*
* @task config Configuring Error Dispatch
* @task exutil Exception Utilities
* @task trap Error Traps
* @task internal Internals
*/
final class PhutilErrorHandler extends Phobject {
private static $errorListeners = array();
private static $initialized = false;
private static $traps = array();
const EXCEPTION = 'exception';
const ERROR = 'error';
const PHLOG = 'phlog';
const DEPRECATED = 'deprecated';
/* -( Configuring Error Dispatch )----------------------------------------- */
/**
* Registers this class as the PHP error and exception handler. This will
* overwrite any previous handlers!
*
* @return void
* @task config
*/
public static function initialize() {
self::$initialized = true;
set_error_handler(array(__CLASS__, 'handleError'));
set_exception_handler(array(__CLASS__, 'handleException'));
}
/**
* Provide an optional listener callback which will receive all errors,
* exceptions and debugging messages. It can then print them to a web console,
* for example.
*
* See @{function:phutil_error_listener_example} for details about the
* callback parameters and operation.
*
* @return void
* @task config
*/
public static function addErrorListener($listener) {
self::$errorListeners[] = $listener;
}
/**
* Deprecated - use `addErrorListener`.
*/
public static function setErrorListener($listener) {
self::addErrorListener($listener);
}
/* -( Exception Utilities )------------------------------------------------ */
/**
* Gets the previous exception of a nested exception. Prior to PHP 5.3 you
* can use @{class:PhutilProxyException} to nest exceptions; after PHP 5.3
* all exceptions are nestable.
*
- * @param Exception|Throwable Exception to unnest.
+ * @param Exception|Throwable $ex Exception to unnest.
* @return Exception|Throwable|null Previous exception, if one exists.
* @task exutil
*/
public static function getPreviousException($ex) {
if (method_exists($ex, 'getPrevious')) {
return $ex->getPrevious();
}
if (method_exists($ex, 'getPreviousException')) {
return $ex->getPreviousException();
}
return null;
}
/**
* Find the most deeply nested exception from a possibly-nested exception.
*
- * @param Exception|Throwable A possibly-nested exception.
+ * @param Exception|Throwable $ex A possibly-nested exception.
* @return Exception|Throwable Deepest exception in the nest.
* @task exutil
*/
public static function getRootException($ex) {
$root = $ex;
while (self::getPreviousException($root)) {
$root = self::getPreviousException($root);
}
return $root;
}
/* -( Trapping Errors )---------------------------------------------------- */
/**
* Adds an error trap. Normally you should not invoke this directly;
* @{class:PhutilErrorTrap} registers itself on construction.
*
- * @param PhutilErrorTrap Trap to add.
+ * @param PhutilErrorTrap $trap Trap to add.
* @return void
* @task trap
*/
public static function addErrorTrap(PhutilErrorTrap $trap) {
$key = $trap->getTrapKey();
self::$traps[$key] = $trap;
}
/**
* Removes an error trap. Normally you should not invoke this directly;
* @{class:PhutilErrorTrap} deregisters itself on destruction.
*
- * @param PhutilErrorTrap Trap to remove.
+ * @param PhutilErrorTrap $trap Trap to remove.
* @return void
* @task trap
*/
public static function removeErrorTrap(PhutilErrorTrap $trap) {
$key = $trap->getTrapKey();
unset(self::$traps[$key]);
}
/* -( Internals )---------------------------------------------------------- */
/**
* Determine if PhutilErrorHandler has been initialized.
*
* @return bool True if initialized.
* @task internal
*/
public static function hasInitialized() {
return self::$initialized;
}
/**
* Handles PHP errors and dispatches them forward. This is a callback for
* ##set_error_handler()##. You should not call this function directly; use
* @{function:phlog} to print debugging messages or ##trigger_error()## to
* trigger PHP errors.
*
* This handler converts E_RECOVERABLE_ERROR messages from violated typehints
* into @{class:InvalidArgumentException}s.
*
* This handler converts other E_RECOVERABLE_ERRORs into
* @{class:RuntimeException}s.
*
* This handler converts E_NOTICE messages from uses of undefined variables
* into @{class:RuntimeException}s.
*
- * @param int Error code.
- * @param string Error message.
- * @param string File where the error occurred.
- * @param int Line on which the error occurred.
- * @param wild Error context information.
+ * @param int $num Error code.
+ * @param string $str Error message.
+ * @param string $file File where the error occurred.
+ * @param int $line Line on which the error occurred.
+ * @param wild $ctx (optional) Error context information.
* @return void
* @task internal
*/
public static function handleError($num, $str, $file, $line, $ctx = null) {
foreach (self::$traps as $trap) {
$trap->addError($num, $str, $file, $line);
}
if ((error_reporting() & $num) == 0) {
// Respect the use of "@" to silence warnings: if this error was
// emitted from a context where "@" was in effect, the
// value returned by error_reporting() will be 0. This is the
// recommended way to check for this, see set_error_handler() docs
// on php.net.
return false;
}
// See T13499. If this is a user error arising from "trigger_error()" or
// similar, route it through normal error handling: this is probably the
// best match to authorial intent, since the code could choose to throw
// an exception instead if it wanted that behavior. Phabricator does not
// use "trigger_error()" so we never normally expect to reach this
// block in first-party code.
if (($num === E_USER_ERROR) ||
($num === E_USER_WARNING) ||
($num === E_USER_NOTICE) ||
($num === E_DEPRECATED)) {
// See T15554 - we special-case E_DEPRECATED because we don't want them
// to kill the process.
$level = ($num === E_DEPRECATED) ? self::DEPRECATED : self::ERROR;
$trace = debug_backtrace();
array_shift($trace);
self::dispatchErrorMessage(
$level,
$str,
array(
'file' => $file,
'line' => $line,
'error_code' => $num,
'trace' => $trace,
));
return;
}
// Convert typehint failures into exceptions.
if (preg_match('/^Argument (\d+) passed to (\S+) must be/', $str)) {
throw new InvalidArgumentException($str);
}
// Convert other E_RECOVERABLE_ERRORs into generic runtime exceptions.
if ($num == E_RECOVERABLE_ERROR) {
throw new RuntimeException($str);
}
// Convert uses of undefined variables into exceptions.
if (preg_match('/^Undefined variable: /', $str)) {
throw new RuntimeException($str);
}
// Convert uses of undefined properties into exceptions.
if (preg_match('/^Undefined property: /', $str)) {
throw new RuntimeException($str);
}
// Convert undefined constants into exceptions. Usually this means there
// is a missing `$` and the program is horribly broken.
if (preg_match('/^Use of undefined constant /', $str)) {
throw new RuntimeException($str);
}
// Convert undefined indexes into exceptions.
if (preg_match('/^Undefined index: /', $str)) {
throw new RuntimeException($str);
}
// Convert undefined offsets into exceptions.
if (preg_match('/^Undefined offset: /', $str)) {
throw new RuntimeException($str);
}
// See T13499. Convert all other runtime errors not handled in a more
// specific way into runtime exceptions.
throw new RuntimeException($str);
}
/**
* Handles PHP exceptions and dispatches them forward. This is a callback for
* ##set_exception_handler()##. You should not call this function directly;
* to print exceptions, pass the exception object to @{function:phlog}.
*
- * @param Exception|Throwable Uncaught exception object.
+ * @param Exception|Throwable $ex Uncaught exception object.
* @return void
* @task internal
*/
public static function handleException($ex) {
self::dispatchErrorMessage(
self::EXCEPTION,
$ex,
array(
'file' => $ex->getFile(),
'line' => $ex->getLine(),
'trace' => self::getExceptionTrace($ex),
'catch_trace' => debug_backtrace(),
));
// Normally, PHP exits with code 255 after an uncaught exception is thrown.
// However, if we install an exception handler (as we have here), it exits
// with code 0 instead. Script execution terminates after this function
// exits in either case, so exit explicitly with the correct exit code.
exit(255);
}
/**
* Output a stacktrace to the PHP error log.
*
- * @param trace A stacktrace, e.g. from debug_backtrace();
+ * @param trace $trace A stacktrace, e.g. from debug_backtrace();
* @return void
* @task internal
*/
public static function outputStacktrace($trace) {
$lines = explode("\n", self::formatStacktrace($trace));
foreach ($lines as $line) {
error_log($line);
}
}
/**
* Format a stacktrace for output.
*
- * @param trace A stacktrace, e.g. from debug_backtrace();
+ * @param trace $trace A stacktrace, e.g. from debug_backtrace();
* @return string Human-readable trace.
* @task internal
*/
public static function formatStacktrace($trace) {
$result = array();
$libinfo = self::getLibraryVersions();
if ($libinfo) {
foreach ($libinfo as $key => $dict) {
$info = array();
foreach ($dict as $dkey => $dval) {
$info[] = $dkey.'='.$dval;
}
$libinfo[$key] = $key.'('.implode(', ', $info).')';
}
$result[] = implode(', ', $libinfo);
}
foreach ($trace as $key => $entry) {
$line = ' #'.$key.' ';
if (!empty($entry['xid'])) {
if ($entry['xid'] != 1) {
$line .= '<#'.$entry['xid'].'> ';
}
}
if (isset($entry['class'])) {
$line .= $entry['class'].'::';
}
$line .= idx($entry, 'function', '');
if (isset($entry['args'])) {
$args = array();
foreach ($entry['args'] as $arg) {
// NOTE: Print out object types, not values. Values sometimes contain
// sensitive information and are usually not particularly helpful
// for debugging.
$type = (gettype($arg) == 'object')
? get_class($arg)
: gettype($arg);
$args[] = $type;
}
$line .= '('.implode(', ', $args).')';
}
if (isset($entry['file'])) {
$file = self::adjustFilePath($entry['file']);
$line .= ' called at ['.$file.':'.$entry['line'].']';
}
$result[] = $line;
}
return implode("\n", $result);
}
/**
* All different types of error messages come here before they are
* dispatched to the listener; this method also prints them to the PHP error
* log.
*
- * @param const Event type constant.
- * @param wild Event value.
- * @param dict Event metadata.
+ * @param const $event Event type constant.
+ * @param wild $value Event value.
+ * @param dict $metadata Event metadata.
* @return void
* @task internal
*/
public static function dispatchErrorMessage($event, $value, $metadata) {
$timestamp = date('Y-m-d H:i:s');
switch ($event) {
case self::DEPRECATED:
case self::ERROR:
$default_message = sprintf(
'[%s] ERROR %d: %s at [%s:%d]',
$timestamp,
$metadata['error_code'],
$value,
$metadata['file'],
$metadata['line']);
$metadata['default_message'] = $default_message;
error_log($default_message);
self::outputStacktrace($metadata['trace']);
break;
case self::EXCEPTION:
$messages = array();
$current = $value;
do {
$messages[] = '('.get_class($current).') '.$current->getMessage();
} while ($current = self::getPreviousException($current));
$messages = implode(' {>} ', $messages);
if (strlen($messages) > 4096) {
$messages = substr($messages, 0, 4096).'...';
}
$default_message = sprintf(
'[%s] EXCEPTION: %s at [%s:%d]',
$timestamp,
$messages,
self::adjustFilePath(self::getRootException($value)->getFile()),
self::getRootException($value)->getLine());
$metadata['default_message'] = $default_message;
error_log($default_message);
self::outputStacktrace($metadata['trace']);
break;
case self::PHLOG:
$default_message = sprintf(
'[%s] PHLOG: %s at [%s:%d]',
$timestamp,
PhutilReadableSerializer::printShort($value),
$metadata['file'],
$metadata['line']);
$metadata['default_message'] = $default_message;
error_log($default_message);
break;
default:
error_log(pht('Unknown event %s', $event));
break;
}
if (self::$errorListeners) {
static $handling_error;
if ($handling_error) {
error_log(
'Error handler was reentered, some errors were not passed to the '.
'listener.');
return;
}
$handling_error = true;
foreach (self::$errorListeners as $error_listener) {
call_user_func($error_listener, $event, $value, $metadata);
}
$handling_error = false;
}
}
public static function adjustFilePath($path) {
// Compute known library locations so we can emit relative paths if the
// file resides inside a known library. This is a little cleaner to read,
// and limits the number of false positives we get about full path
// disclosure via HackerOne.
$bootloader = PhutilBootloader::getInstance();
$libraries = $bootloader->getAllLibraries();
$roots = array();
foreach ($libraries as $library) {
$root = $bootloader->getLibraryRoot($library);
// For these libraries, the effective root is one level up.
switch ($library) {
case 'arcanist':
case 'phorge':
case 'phabricator':
$root = dirname($root);
break;
}
if (!strncmp($root, $path, strlen($root))) {
return '<'.$library.'>'.substr($path, strlen($root));
}
}
return $path;
}
public static function getLibraryVersions() {
$libinfo = array();
$bootloader = PhutilBootloader::getInstance();
foreach ($bootloader->getAllLibraries() as $library) {
$root = phutil_get_library_root($library);
$try_paths = array(
$root,
dirname($root),
);
$libinfo[$library] = array();
$get_refs = array('master');
foreach ($try_paths as $try_path) {
// Try to read what the HEAD of the repository is pointed at. This is
// normally the name of a branch ("ref").
$try_file = $try_path.'/.git/HEAD';
if (@file_exists($try_file)) {
$head = @file_get_contents($try_file);
$matches = null;
if (preg_match('(^ref: refs/heads/(.*)$)', trim($head), $matches)) {
$libinfo[$library]['head'] = trim($matches[1]);
$get_refs[] = trim($matches[1]);
} else {
$libinfo[$library]['head'] = trim($head);
}
break;
}
}
// Try to read which commit relevant branch heads are at.
foreach (array_unique($get_refs) as $ref) {
foreach ($try_paths as $try_path) {
$try_file = $try_path.'/.git/refs/heads/'.$ref;
if (@file_exists($try_file)) {
$hash = @file_get_contents($try_file);
if ($hash) {
$libinfo[$library]['ref.'.$ref] = substr(trim($hash), 0, 12);
break;
}
}
}
}
// Look for extension files.
$custom = @scandir($root.'/extensions/');
if ($custom) {
$count = 0;
foreach ($custom as $custom_path) {
if (preg_match('/\.php$/', $custom_path)) {
$count++;
}
}
if ($count) {
$libinfo[$library]['custom'] = $count;
}
}
}
ksort($libinfo);
return $libinfo;
}
/**
* Get a full trace across all proxied and aggregated exceptions.
*
* This attempts to build a set of stack frames which completely represent
* all of the places an exception came from, even if it came from multiple
* origins and has been aggregated or proxied.
*
- * @param Exception|Throwable Exception to retrieve a trace for.
+ * @param Exception|Throwable $ex Exception to retrieve a trace for.
* @return list<wild> List of stack frames.
*/
public static function getExceptionTrace($ex) {
$id = 1;
// Keep track of discovered exceptions which we need to build traces for.
$stack = array(
array($id, $ex),
);
$frames = array();
while ($info = array_shift($stack)) {
list($xid, $ex) = $info;
// We're going from top-level exception down in bredth-first order, but
// want to build a trace in approximately standard order (deepest part of
// the call stack to most shallow) so we need to reverse each list of
// frames and then reverse everything at the end.
$ex_frames = array_reverse($ex->getTrace());
$ex_frames = array_values($ex_frames);
$last_key = (count($ex_frames) - 1);
foreach ($ex_frames as $frame_key => $frame) {
$frame['xid'] = $xid;
// If this is a child/previous exception and we're on the deepest frame
// and missing file/line data, fill it in from the exception itself.
if ($xid > 1 && ($frame_key == $last_key)) {
if (empty($frame['file'])) {
$frame['file'] = $ex->getFile();
$frame['line'] = $ex->getLine();
}
}
// Since the exceptions are likely to share the most shallow frames,
// try to add those to the trace only once.
if (isset($frame['file']) && isset($frame['line'])) {
$signature = $frame['file'].':'.$frame['line'];
if (empty($frames[$signature])) {
$frames[$signature] = $frame;
}
} else {
$frames[] = $frame;
}
}
// If this is a proxy exception, add the proxied exception.
$prev = self::getPreviousException($ex);
if ($prev) {
$stack[] = array(++$id, $prev);
}
// If this is an aggregate exception, add the child exceptions.
if ($ex instanceof PhutilAggregateException) {
foreach ($ex->getExceptions() as $child) {
$stack[] = array(++$id, $child);
}
}
}
return array_values(array_reverse($frames));
}
}
diff --git a/src/error/phlog.php b/src/error/phlog.php
index 52dc6af9..6dff4f06 100644
--- a/src/error/phlog.php
+++ b/src/error/phlog.php
@@ -1,68 +1,68 @@
<?php
/**
* libphutil log function for development debugging. Takes any argument and
* forwards it to registered listeners. This is essentially a more powerful
* version of `error_log()`.
*
- * @param wild Any value you want printed to the error log or other registered
- * logs/consoles.
+ * @param wild $value Any value you want printed to the error log or other
+ * registered logs/consoles.
* @param ... Other values to be logged.
* @return wild Passed $value.
*/
function phlog($value/* , ... */) {
// Get the caller information.
$trace = debug_backtrace();
$metadata = array(
'file' => $trace[0]['file'],
'line' => $trace[0]['line'],
'trace' => $trace,
);
foreach (func_get_args() as $event) {
$data = $metadata;
if (($event instanceof Exception) || ($event instanceof Throwable)) {
$type = PhutilErrorHandler::EXCEPTION;
// If this is an exception, proxy it and generate a composite trace which
// shows both where the phlog() was called and where the exception was
// originally thrown from.
$proxy = new PhutilProxyException('', $event);
$trace = PhutilErrorHandler::getExceptionTrace($proxy);
$data['trace'] = $trace;
} else {
$type = PhutilErrorHandler::PHLOG;
}
PhutilErrorHandler::dispatchErrorMessage($type, $event, $data);
}
return $value;
}
/**
* Example @{class:PhutilErrorHandler} error listener callback. When you call
* `PhutilErrorHandler::addErrorListener()`, you must pass a callback function
* with the same signature as this one.
*
* NOTE: @{class:PhutilErrorHandler} handles writing messages to the error
* log, so you only need to provide a listener if you have some other console
* (like Phabricator's DarkConsole) which you //also// want to send errors to.
*
* NOTE: You will receive errors which were silenced with the `@` operator. If
* you don't want to display these, test for `@` being in effect by checking if
* `error_reporting() === 0` before displaying the error.
*
- * @param const A PhutilErrorHandler constant, like PhutilErrorHandler::ERROR,
- * which indicates the event type (e.g. error, exception,
- * user message).
- * @param wild The event value, like the Exception object for an exception
- * event, an error string for an error event, or some user object
- * for user messages.
- * @param dict A dictionary of metadata about the event. The keys 'file',
- * 'line' and 'trace' are always available. Other keys may be
- * present, depending on the event type.
+ * @param const $event A PhutilErrorHandler constant, like
+ * PhutilErrorHandler::ERROR, which indicates the event type
+ * (e.g. error, exception, user message).
+ * @param wild $value The event value, like the Exception object for an
+ * exception event, an error string for an error event, or some
+ * user object for user messages.
+ * @param dict $metadata A dictionary of metadata about the event. The keys
+ * 'file', 'line' and 'trace' are always available. Other keys
+ * may be present, depending on the event type.
* @return void
*/
function phutil_error_listener_example($event, $value, array $metadata) {
throw new Exception(pht('This is just an example function!'));
}
diff --git a/src/filesystem/FileFinder.php b/src/filesystem/FileFinder.php
index 6b1dbbb4..4eb2c3fd 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 directory to find files beneath.
+ * @param string $root Root directory to find files beneath.
* @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 Either "php", "shell", or the empty string.
+ * @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 656c92de..89eb6f7d 100644
--- a/src/filesystem/FileList.php
+++ b/src/filesystem/FileList.php
@@ -1,92 +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 List of relative or absolute file paths.
+ * @param list $paths List of relative or absolute file paths.
* @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 Relative or absolute system file path.
- * @param bool 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.
+ * @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/Filesystem.php b/src/filesystem/Filesystem.php
index dd6e984a..ca93f4cf 100644
--- a/src/filesystem/Filesystem.php
+++ b/src/filesystem/Filesystem.php
@@ -1,1307 +1,1311 @@
<?php
/**
* Simple wrapper class for common filesystem tasks like reading and writing
* files. When things go wrong, this class throws detailed exceptions with
* good information about what didn't work.
*
* Filesystem will resolve relative paths against PWD from the environment.
* When Filesystem is unable to complete an operation, it throws a
* FilesystemException.
*
* @task directory Directories
* @task file Files
* @task path Paths
* @task exec Executables
* @task assert Assertions
*/
final class Filesystem extends Phobject {
/* -( Files )-------------------------------------------------------------- */
/**
* Read a file in a manner similar to file_get_contents(), but throw detailed
* exceptions on failure.
*
- * @param string File path to read. This file must exist and be readable,
- * or an exception will be thrown.
+ * @param string $path File path to read. This file must exist and be
+ * readable, or an exception will be thrown.
* @return string Contents of the specified file.
*
* @task file
*/
public static function readFile($path) {
$path = self::resolvePath($path);
self::assertExists($path);
self::assertIsFile($path);
self::assertReadable($path);
$data = @file_get_contents($path);
if ($data === false) {
throw new FilesystemException(
$path,
pht("Failed to read file '%s'.", $path));
}
return $data;
}
/**
* Make assertions about the state of path in preparation for
* writeFile() and writeFileIfChanged().
*/
public static function assertWritableFile($path) {
$path = self::resolvePath($path);
$dir = dirname($path);
self::assertExists($dir);
self::assertIsDirectory($dir);
// File either needs to not exist and have a writable parent, or be
// writable itself.
$exists = true;
try {
self::assertNotExists($path);
$exists = false;
} catch (Exception $ex) {
self::assertWritable($path);
}
if (!$exists) {
self::assertWritable($dir);
}
}
/**
* Write a file in a manner similar to file_put_contents(), but throw
* detailed exceptions on failure. If the file already exists, it will be
* overwritten.
*
- * @param string File path to write. This file must be writable and its
- * parent directory must exist.
- * @param string Data to write.
+ * @param string $path File path to write. This file must be writable and
+ * its parent directory must exist.
+ * @param string $data Data to write.
*
* @task file
*/
public static function writeFile($path, $data) {
self::assertWritableFile($path);
if (@file_put_contents($path, $data) === false) {
throw new FilesystemException(
$path,
pht("Failed to write file '%s'.", $path));
}
}
/**
* Write a file in a manner similar to `file_put_contents()`, but only touch
* the file if the contents are different, and throw detailed exceptions on
* failure.
*
* As this function is used in build steps to update code, if we write a new
* file, we do so by writing to a temporary file and moving it into place.
* This allows a concurrently reading process to see a consistent view of the
* file without needing locking; any given read of the file is guaranteed to
* be self-consistent and not see partial file contents.
*
- * @param string file path to write
- * @param string data to write
+ * @param string $path file path to write
+ * @param string $data data to write
*
* @return boolean indicating whether the file was changed by this function.
*/
public static function writeFileIfChanged($path, $data) {
if (file_exists($path)) {
$current = self::readFile($path);
if ($current === $data) {
return false;
}
}
self::assertWritableFile($path);
// Create the temporary file alongside the intended destination,
// as this ensures that the rename() will be atomic (on the same fs)
$dir = dirname($path);
$temp = tempnam($dir, 'GEN');
if (!$temp) {
throw new FilesystemException(
$dir,
pht('Unable to create temporary file in %s.', $dir));
}
try {
self::writeFile($temp, $data);
// tempnam will always restrict ownership to us, broaden
// it so that these files respect the actual umask
self::changePermissions($temp, 0666 & ~umask());
// This will appear atomic to concurrent readers
$ok = rename($temp, $path);
if (!$ok) {
throw new FilesystemException(
$path,
pht('Unable to move %s to %s.', $temp, $path));
}
} catch (Exception $e) {
// Make best effort to remove temp file
unlink($temp);
throw $e;
}
return true;
}
/**
* Write data to unique file, without overwriting existing files. This is
* useful if you want to write a ".bak" file or something similar, but want
* to make sure you don't overwrite something already on disk.
*
* This function will add a number to the filename if the base name already
* exists, e.g. "example.bak", "example.bak.1", "example.bak.2", etc. (Don't
* rely on this exact behavior, of course.)
*
- * @param string Suggested filename, like "example.bak". This name will
- * be used if it does not exist, or some similar name will
- * be chosen if it does.
- * @param string Data to write to the file.
+ * @param string $base Suggested filename, like "example.bak". This name
+ * will be used if it does not exist, or some similar name
+ * will be chosen if it does.
+ * @param string $data Data to write to the file.
* @return string Path to a newly created and written file which did not
* previously exist, like "example.bak.3".
* @task file
*/
public static function writeUniqueFile($base, $data) {
$full_path = self::resolvePath($base);
$sequence = 0;
assert_stringlike($data);
// Try 'file', 'file.1', 'file.2', etc., until something doesn't exist.
while (true) {
$try_path = $full_path;
if ($sequence) {
$try_path .= '.'.$sequence;
}
$handle = @fopen($try_path, 'x');
if ($handle) {
$ok = fwrite($handle, $data);
if ($ok === false) {
throw new FilesystemException(
$try_path,
pht('Failed to write file data.'));
}
$ok = fclose($handle);
if (!$ok) {
throw new FilesystemException(
$try_path,
pht('Failed to close file handle.'));
}
return $try_path;
}
$sequence++;
}
}
/**
* Append to a file without having to deal with file handles, with
* detailed exceptions on failure.
*
- * @param string File path to write. This file must be writable or its
- * parent directory must exist and be writable.
- * @param string Data to write.
+ * @param string $path File path to write. This file must be writable or
+ * its parent directory must exist and be writable.
+ * @param string $data Data to write.
*
* @task file
*/
public static function appendFile($path, $data) {
$path = self::resolvePath($path);
// Use self::writeFile() if the file doesn't already exist
try {
self::assertExists($path);
} catch (FilesystemException $ex) {
self::writeFile($path, $data);
return;
}
// File needs to exist or the directory needs to be writable
$dir = dirname($path);
self::assertExists($dir);
self::assertIsDirectory($dir);
self::assertWritable($dir);
assert_stringlike($data);
if (($fh = fopen($path, 'a')) === false) {
throw new FilesystemException(
$path,
pht("Failed to open file '%s'.", $path));
}
$dlen = strlen($data);
if (fwrite($fh, $data) !== $dlen) {
throw new FilesystemException(
$path,
pht("Failed to write %d bytes to '%s'.", $dlen, $path));
}
if (!fflush($fh) || !fclose($fh)) {
throw new FilesystemException(
$path,
pht("Failed closing file '%s' after write.", $path));
}
}
/**
* Copy a file, preserving file attributes (if relevant for the OS).
*
- * @param string File path to copy from. This file must exist and be
+ * @param string $from File path to copy from. This file must exist and be
* readable, or an exception will be thrown.
- * @param string File path to copy to. If a file exists at this path
+ * @param string $to File path to copy to. If a file exists at this path
* already, it wll be overwritten.
*
* @task file
*/
public static function copyFile($from, $to) {
$from = self::resolvePath($from);
$to = self::resolvePath($to);
self::assertExists($from);
self::assertIsFile($from);
self::assertReadable($from);
if (phutil_is_windows()) {
$trap = new PhutilErrorTrap();
$ok = @copy($from, $to);
$err = $trap->getErrorsAsString();
$trap->destroy();
if (!$ok) {
if ($err !== null && strlen($err)) {
throw new FilesystemException(
$to,
pht(
'Failed to copy file from "%s" to "%s": %s',
$from,
$to,
$err));
} else {
throw new FilesystemException(
$to,
pht(
'Failed to copy file from "%s" to "%s".',
$from,
$to));
}
}
} else {
execx('cp -p %s %s', $from, $to);
}
}
/**
* Remove a file or directory.
*
- * @param string File to a path or directory to remove.
+ * @param string $path File to a path or directory to remove.
* @return void
*
* @task file
*/
public static function remove($path) {
if ($path == null || !strlen($path)) {
// Avoid removing PWD.
throw new Exception(
pht(
'No path provided to %s.',
__FUNCTION__.'()'));
}
$path = self::resolvePath($path);
if (!file_exists($path)) {
return;
}
self::executeRemovePath($path);
}
/**
* Rename a file or directory.
*
- * @param string Old path.
- * @param string New path.
+ * @param string $old Old path.
+ * @param string $new New path.
*
* @task file
*/
public static function rename($old, $new) {
$old = self::resolvePath($old);
$new = self::resolvePath($new);
self::assertExists($old);
$ok = rename($old, $new);
if (!$ok) {
throw new FilesystemException(
$new,
pht("Failed to rename '%s' to '%s'!", $old, $new));
}
}
/**
* Internal. Recursively remove a file or an entire directory. Implements
* the core function of @{method:remove} in a way that works on Windows.
*
- * @param string File to a path or directory to remove.
+ * @param string $path File to a path or directory to remove.
* @return void
*
* @task file
*/
private static function executeRemovePath($path) {
if (is_dir($path) && !is_link($path)) {
foreach (self::listDirectory($path, true) as $child) {
self::executeRemovePath($path.DIRECTORY_SEPARATOR.$child);
}
$ok = rmdir($path);
if (!$ok) {
throw new FilesystemException(
$path,
pht("Failed to remove directory '%s'!", $path));
}
} else {
$ok = unlink($path);
if (!$ok) {
throw new FilesystemException(
$path,
pht("Failed to remove file '%s'!", $path));
}
}
}
/**
* Change the permissions of a file or directory.
*
- * @param string Path to the file or directory.
- * @param int Permission umask. Note that umask is in octal, so you
- * should specify it as, e.g., `0777', not `777'.
+ * @param string $path Path to the file or directory.
+ * @param int $umask Permission umask. Note that umask is in octal, so
+ * you should specify it as, e.g., `0777', not `777'.
* @return void
*
* @task file
*/
public static function changePermissions($path, $umask) {
$path = self::resolvePath($path);
self::assertExists($path);
if (!@chmod($path, $umask)) {
$readable_umask = sprintf('%04o', $umask);
throw new FilesystemException(
$path,
pht("Failed to chmod '%s' to '%s'.", $path, $readable_umask));
}
}
/**
* Get the last modified time of a file
*
- * @param string Path to file
+ * @param string $path Path to file
* @return int Time last modified
*
* @task file
*/
public static function getModifiedTime($path) {
$path = self::resolvePath($path);
self::assertExists($path);
self::assertIsFile($path);
self::assertReadable($path);
$modified_time = @filemtime($path);
if ($modified_time === false) {
throw new FilesystemException(
$path,
pht('Failed to read modified time for %s.', $path));
}
return $modified_time;
}
/**
* Read random bytes from /dev/urandom or equivalent. See also
* @{method:readRandomCharacters}.
*
- * @param int Number of bytes to read.
+ * @param int $number_of_bytes Number of bytes to read.
* @return string Random bytestring of the provided length.
*
* @task file
*/
public static function readRandomBytes($number_of_bytes) {
$number_of_bytes = (int)$number_of_bytes;
if ($number_of_bytes < 1) {
throw new Exception(pht('You must generate at least 1 byte of entropy.'));
}
// Under PHP 7.2.0 and newer, we have a reasonable builtin. For older
// versions, we fall back to various sources which have a roughly similar
// effect.
if (function_exists('random_bytes')) {
return random_bytes($number_of_bytes);
}
// Try to use `openssl_random_pseudo_bytes()` if it's available. This source
// is the most widely available source, and works on Windows/Linux/OSX/etc.
if (function_exists('openssl_random_pseudo_bytes')) {
$strong = true;
$data = openssl_random_pseudo_bytes($number_of_bytes, $strong);
if (!$strong) {
// NOTE: This indicates we're using a weak random source. This is
// probably OK, but maybe we should be more strict here.
}
if ($data === false) {
throw new Exception(
pht(
'%s failed to generate entropy!',
'openssl_random_pseudo_bytes()'));
}
if (strlen($data) != $number_of_bytes) {
throw new Exception(
pht(
'%s returned an unexpected number of bytes (got %s, expected %s)!',
'openssl_random_pseudo_bytes()',
new PhutilNumber(strlen($data)),
new PhutilNumber($number_of_bytes)));
}
return $data;
}
// Try to use `/dev/urandom` if it's available. This is usually available
// on non-Windows systems, but some PHP config (open_basedir) and chrooting
// may limit our access to it.
$urandom = @fopen('/dev/urandom', 'rb');
if ($urandom) {
$data = @fread($urandom, $number_of_bytes);
@fclose($urandom);
if (strlen($data) != $number_of_bytes) {
throw new FilesystemException(
'/dev/urandom',
pht('Failed to read random bytes!'));
}
return $data;
}
// (We might be able to try to generate entropy here from a weaker source
// if neither of the above sources panned out, see some discussion in
// T4153.)
// We've failed to find any valid entropy source. Try to fail in the most
// useful way we can, based on the platform.
if (phutil_is_windows()) {
throw new Exception(
pht(
'%s requires the PHP OpenSSL extension to be installed and enabled '.
'to access an entropy source. On Windows, this extension is usually '.
'installed but not enabled by default. Enable it in your "php.ini".',
__METHOD__.'()'));
}
throw new Exception(
pht(
'%s requires the PHP OpenSSL extension or access to "%s". Install or '.
'enable the OpenSSL extension, or make sure "%s" is accessible.',
__METHOD__.'()',
'/dev/urandom',
'/dev/urandom'));
}
/**
* Read random alphanumeric characters from /dev/urandom or equivalent. This
* method operates like @{method:readRandomBytes} but produces alphanumeric
* output (a-z, 0-9) so it's appropriate for use in URIs and other contexts
* where it needs to be human readable.
*
- * @param int Number of characters to read.
+ * @param int $number_of_characters Number of characters to read.
* @return string Random character string of the provided length.
*
* @task file
*/
public static function readRandomCharacters($number_of_characters) {
// NOTE: To produce the character string, we generate a random byte string
// of the same length, select the high 5 bits from each byte, and
// map that to 32 alphanumeric characters. This could be improved (we
// could improve entropy per character with base-62, and some entropy
// sources might be less entropic if we discard the low bits) but for
// reasonable cases where we have a good entropy source and are just
// generating some kind of human-readable secret this should be more than
// sufficient and is vastly simpler than trying to do bit fiddling.
$map = array_merge(range('a', 'z'), range('2', '7'));
$result = '';
$bytes = self::readRandomBytes($number_of_characters);
for ($ii = 0; $ii < $number_of_characters; $ii++) {
$result .= $map[ord($bytes[$ii]) >> 3];
}
return $result;
}
/**
* Generate a random integer value in a given range.
*
* This method uses less-entropic random sources under older versions of PHP.
*
- * @param int Minimum value, inclusive.
- * @param int Maximum value, inclusive.
+ * @param int $min Minimum value, inclusive.
+ * @param int $max Maximum value, inclusive.
*/
public static function readRandomInteger($min, $max) {
if (!is_int($min)) {
throw new Exception(pht('Minimum value must be an integer.'));
}
if (!is_int($max)) {
throw new Exception(pht('Maximum value must be an integer.'));
}
if ($min > $max) {
throw new Exception(
pht(
'Minimum ("%d") must not be greater than maximum ("%d").',
$min,
$max));
}
// Under PHP 7.2.0 and newer, we can just use "random_int()". This function
// is intended to generate cryptographically usable entropy.
if (function_exists('random_int')) {
return random_int($min, $max);
}
// We could find a stronger source for this, but correctly converting raw
// bytes to an integer range without biases is fairly hard and it seems
// like we're more likely to get that wrong than suffer a PRNG prediction
// issue by falling back to "mt_rand()".
if (($max - $min) > mt_getrandmax()) {
throw new Exception(
pht('mt_rand() range is smaller than the requested range.'));
}
$result = mt_rand($min, $max);
if (!is_int($result)) {
throw new Exception(pht('Bad return value from mt_rand().'));
}
return $result;
}
/**
* Identify the MIME type of a file. This returns only the MIME type (like
* text/plain), not the encoding (like charset=utf-8).
*
- * @param string Path to the file to examine.
- * @param string Optional default mime type to return if the file's mime
- * type can not be identified.
+ * @param string $path Path to the file to examine.
+ * @param string $default (optional) default mime type to return if the
+ * file's mime type can not be identified.
* @return string File mime type.
*
* @task file
*
* @phutil-external-symbol function mime_content_type
* @phutil-external-symbol function finfo_open
* @phutil-external-symbol function finfo_file
*/
public static function getMimeType(
$path,
$default = 'application/octet-stream') {
$path = self::resolvePath($path);
self::assertExists($path);
self::assertIsFile($path);
self::assertReadable($path);
$mime_type = null;
// Fileinfo is the best approach since it doesn't rely on `file`, but
// it isn't builtin for older versions of PHP.
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME);
if ($finfo) {
$result = finfo_file($finfo, $path);
if ($result !== false) {
$mime_type = $result;
}
}
}
// If we failed Fileinfo, try `file`. This works well but not all systems
// have the binary.
if ($mime_type === null) {
list($err, $stdout) = exec_manual(
'file --brief --mime %s',
$path);
if (!$err) {
$mime_type = trim($stdout);
}
}
// If we didn't get anywhere, try the deprecated mime_content_type()
// function.
if ($mime_type === null) {
if (function_exists('mime_content_type')) {
$result = mime_content_type($path);
if ($result !== false) {
$mime_type = $result;
}
}
}
// If we come back with an encoding, strip it off.
if ($mime_type !== null && strpos($mime_type, ';') !== false) {
list($type, $encoding) = explode(';', $mime_type, 2);
$mime_type = $type;
}
if ($mime_type === null) {
$mime_type = $default;
}
return $mime_type;
}
/* -( Directories )-------------------------------------------------------- */
/**
* Create a directory in a manner similar to mkdir(), but throw detailed
* exceptions on failure.
*
- * @param string Path to directory. The parent directory must exist and
- * be writable.
- * @param int Permission umask. Note that umask is in octal, so you
- * should specify it as, e.g., `0777', not `777'.
- * @param boolean Recursively create directories. Default to false.
+ * @param string $path Path to directory. The parent directory must exist
+ * and be writable.
+ * @param int $umask Permission umask. Note that umask is in octal, so
+ * you should specify it as, e.g., `0777', not `777'.
+ * @param boolean $recursive (optional) Recursively create directories.
+ * Defaults to false.
* @return string Path to the created directory.
*
* @task directory
*/
public static function createDirectory(
$path,
$umask = 0755,
$recursive = false) {
$path = self::resolvePath($path);
if (is_dir($path)) {
if ($umask) {
self::changePermissions($path, $umask);
}
return $path;
}
$dir = dirname($path);
if ($recursive && !file_exists($dir)) {
// Note: We could do this with the recursive third parameter of mkdir(),
// but then we loose the helpful FilesystemExceptions we normally get.
self::createDirectory($dir, $umask, true);
}
self::assertIsDirectory($dir);
self::assertExists($dir);
self::assertWritable($dir);
self::assertNotExists($path);
if (!mkdir($path, $umask)) {
throw new FilesystemException(
$path,
pht("Failed to create directory '%s'.", $path));
}
// Need to change permissions explicitly because mkdir does something
// slightly different. mkdir(2) man page:
// 'The parameter mode specifies the permissions to use. It is modified by
// the process's umask in the usual way: the permissions of the created
// directory are (mode & ~umask & 0777)."'
if ($umask) {
self::changePermissions($path, $umask);
}
return $path;
}
/**
* Create a temporary directory and return the path to it. You are
* responsible for removing it (e.g., with Filesystem::remove())
* when you are done with it.
*
- * @param string Optional directory prefix.
- * @param int Permissions to create the directory with. By default,
- * these permissions are very restrictive (0700).
- * @param string Optional root directory. If not provided, the system
- * temporary directory (often "/tmp") will be used.
+ * @param string $prefix (optional) directory prefix.
+ * @param int $umask (optional) Permissions to create the directory
+ * with. By default, these permissions are very restrictive
+ * (0700).
+ * @param string $root_directory (optional) Root directory. If not
+ * provided, the system temporary directory (often "/tmp")
+ * will be used.
* @return string Path to newly created temporary directory.
*
* @task directory
*/
public static function createTemporaryDirectory(
$prefix = '',
$umask = 0700,
$root_directory = null) {
$prefix = preg_replace('/[^A-Z0-9._-]+/i', '', $prefix);
if ($root_directory !== null) {
$tmp = $root_directory;
self::assertExists($tmp);
self::assertIsDirectory($tmp);
self::assertWritable($tmp);
} else {
$tmp = sys_get_temp_dir();
if (!$tmp) {
throw new FilesystemException(
$tmp,
pht('Unable to determine system temporary directory.'));
}
}
$base = $tmp.DIRECTORY_SEPARATOR.$prefix;
$tries = 3;
do {
$dir = $base.substr(base_convert(md5(mt_rand()), 16, 36), 0, 16);
try {
self::createDirectory($dir, $umask);
break;
} catch (FilesystemException $ex) {
// Ignore.
}
} while (--$tries);
if (!$tries) {
$df = disk_free_space($tmp);
if ($df !== false && $df < 1024 * 1024) {
throw new FilesystemException(
$dir,
pht('Failed to create a temporary directory: the disk is full.'));
}
throw new FilesystemException(
$dir,
pht("Failed to create a temporary directory in '%s'.", $tmp));
}
return $dir;
}
/**
* List files in a directory.
*
- * @param string Path, absolute or relative to PWD.
- * @param bool If false, exclude files beginning with a ".".
+ * @param string $path Path, absolute or relative to PWD.
+ * @param bool $include_hidden If false, exclude files beginning with
+ * a ".".
*
* @return array List of files and directories in the specified
* directory, excluding `.' and `..'.
*
* @task directory
*/
public static function listDirectory($path, $include_hidden = true) {
$path = self::resolvePath($path);
self::assertExists($path);
self::assertIsDirectory($path);
self::assertReadable($path);
$list = @scandir($path);
if ($list === false) {
throw new FilesystemException(
$path,
pht("Unable to list contents of directory '%s'.", $path));
}
foreach ($list as $k => $v) {
if ($v == '.' || $v == '..' || (!$include_hidden && $v[0] == '.')) {
unset($list[$k]);
}
}
return array_values($list);
}
/**
* Return all directories between a path and the specified root directory
* (defaulting to "/"). Iterating over them walks from the path to the root.
*
- * @param string Path, absolute or relative to PWD.
- * @param string The root directory.
+ * @param string $path Path, absolute or relative to PWD.
+ * @param string $root (optional) The root directory.
* @return list<string> List of parent paths, including the provided path.
* @task directory
*/
public static function walkToRoot($path, $root = null) {
$path = self::resolvePath($path);
if (is_link($path)) {
$path = realpath($path);
}
// NOTE: On Windows, paths start like "C:\", so "/" does not contain
// every other path. We could possibly special case "/" to have the same
// meaning on Windows that it does on Linux, but just special case the
// common case for now. See PHI817.
if ($root !== null) {
$root = self::resolvePath($root);
if (is_link($root)) {
$root = realpath($root);
}
// NOTE: We don't use `isDescendant()` here because we don't want to
// reject paths which don't exist on disk.
$root_list = new FileList(array($root));
if (!$root_list->contains($path)) {
return array();
}
} else {
if (phutil_is_windows()) {
$root = null;
} else {
$root = '/';
}
}
$walk = array();
$parts = explode(DIRECTORY_SEPARATOR, $path);
foreach ($parts as $k => $part) {
if (!strlen($part)) {
unset($parts[$k]);
}
}
while (true) {
if (phutil_is_windows()) {
$next = implode(DIRECTORY_SEPARATOR, $parts);
} else {
$next = DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, $parts);
}
$walk[] = $next;
if ($next == $root) {
break;
}
if (!$parts) {
break;
}
array_pop($parts);
}
return $walk;
}
/* -( Paths )-------------------------------------------------------------- */
/**
* Checks if a path is specified as an absolute path.
*
- * @param string
+ * @param string $path
* @return bool
*/
public static function isAbsolutePath($path) {
if (phutil_is_windows()) {
return (bool)preg_match('/^[A-Za-z]+:/', $path);
} else {
return !strncmp($path, DIRECTORY_SEPARATOR, 1);
}
}
/**
* Canonicalize a path by resolving it relative to some directory (by
* default PWD), following parent symlinks and removing artifacts. If the
* path is itself a symlink it is left unresolved.
*
- * @param string Path, absolute or relative to PWD.
- * @return string Canonical, absolute path.
+ * @param string $path Path, absolute or relative to PWD.
+ * @return string $relative_to (optional) Canonical, absolute path.
*
* @task path
*/
public static function resolvePath($path, $relative_to = null) {
$is_absolute = self::isAbsolutePath($path);
if (!$is_absolute) {
if (!$relative_to) {
$relative_to = getcwd();
}
$path = $relative_to.DIRECTORY_SEPARATOR.$path;
}
if (is_link($path)) {
$parent_realpath = realpath(dirname($path));
if ($parent_realpath !== false) {
return $parent_realpath.DIRECTORY_SEPARATOR.basename($path);
}
}
$realpath = realpath($path);
if ($realpath !== false) {
return $realpath;
}
// This won't work if the file doesn't exist or is on an unreadable mount
// or something crazy like that. Try to resolve a parent so we at least
// cover the nonexistent file case.
// We're also normalizing path separators to whatever is normal for the
// environment.
if (phutil_is_windows()) {
$parts = trim($path, '/\\');
$parts = preg_split('([/\\\\])', $parts);
// Normalize the directory separators in the path. If we find a parent
// below, we'll overwrite this with a better resolved path.
$path = str_replace('/', '\\', $path);
} else {
$parts = trim($path, '/');
$parts = explode('/', $parts);
}
while ($parts) {
array_pop($parts);
if (phutil_is_windows()) {
$attempt = implode(DIRECTORY_SEPARATOR, $parts);
} else {
$attempt = DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, $parts);
}
$realpath = realpath($attempt);
if ($realpath !== false) {
$path = $realpath.substr($path, strlen($attempt));
break;
}
}
return $path;
}
/**
* Test whether a path is descendant from some root path after resolving all
* symlinks and removing artifacts. Both paths must exists for the relation
* to obtain. A path is always a descendant of itself as long as it exists.
*
- * @param string Child path, absolute or relative to PWD.
- * @param string Root path, absolute or relative to PWD.
+ * @param string $path Child path, absolute or relative to PWD.
+ * @param string $root Root path, absolute or relative to PWD.
* @return bool True if resolved child path is in fact a descendant of
* resolved root path and both exist.
* @task path
*/
public static function isDescendant($path, $root) {
try {
self::assertExists($path);
self::assertExists($root);
} catch (FilesystemException $e) {
return false;
}
$fs = new FileList(array($root));
return $fs->contains($path);
}
/**
* Convert a canonical path to its most human-readable format. It is
* guaranteed that you can use resolvePath() to restore a path to its
* canonical format.
*
- * @param string Path, absolute or relative to PWD.
- * @param string Optionally, working directory to make files readable
+ * @param string $path Path, absolute or relative to PWD.
+ * @param string $pwd (optional) Working directory to make files readable
* relative to.
* @return string Human-readable path.
*
* @task path
*/
public static function readablePath($path, $pwd = null) {
if ($pwd === null) {
$pwd = getcwd();
}
foreach (array($pwd, self::resolvePath($pwd)) as $parent) {
$parent = rtrim($parent, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$len = strlen($parent);
if (!strncmp($parent, $path, $len)) {
$path = substr($path, $len);
return $path;
}
}
return $path;
}
/**
* Determine whether or not a path exists in the filesystem. This differs from
* file_exists() in that it returns true for symlinks. This method does not
* attempt to resolve paths before testing them.
*
- * @param string Test for the existence of this path.
+ * @param string $path Test for the existence of this path.
* @return bool True if the path exists in the filesystem.
* @task path
*/
public static function pathExists($path) {
return file_exists($path) || is_link($path);
}
/**
* Determine if an executable binary (like `git` or `svn`) exists within
* the configured `$PATH`.
*
- * @param string Binary name, like `'git'` or `'svn'`.
+ * @param string $binary Binary name, like `'git'` or `'svn'`.
* @return bool True if the binary exists and is executable.
* @task exec
*/
public static function binaryExists($binary) {
return self::resolveBinary($binary) !== null;
}
/**
* Locates the full path that an executable binary (like `git` or `svn`) is at
* the configured `$PATH`.
*
- * @param string Binary name, like `'git'` or `'svn'`.
+ * @param string $binary Binary name, like `'git'` or `'svn'`.
* @return string The full binary path if it is present, or null.
* @task exec
*/
public static function resolveBinary($binary) {
if (phutil_is_windows()) {
list($err, $stdout) = exec_manual('where %s', $binary);
$stdout = phutil_split_lines($stdout);
// If `where %s` could not find anything, check for relative binary
if ($err) {
$path = self::resolvePath($binary);
if (self::pathExists($path)) {
return $path;
}
return null;
}
// These are the only file extensions that can be executed directly
// when using proc_open() with 'bypass_shell'.
$executable_extensions = ['exe', 'bat', 'cmd', 'com'];
foreach ($stdout as $line) {
$path = trim($line);
$ext = pathinfo($path, PATHINFO_EXTENSION);
if (in_array($ext, $executable_extensions)) {
return $path;
}
}
return null;
} else {
list($err, $stdout) = exec_manual('which %s', $binary);
return $err === 0 ? trim($stdout) : null;
}
}
/**
* Determine if two paths are equivalent by resolving symlinks. This is
* different from resolving both paths and comparing them because
* resolvePath() only resolves symlinks in parent directories, not the
* path itself.
*
- * @param string First path to test for equivalence.
- * @param string Second path to test for equivalence.
+ * @param string $u First path to test for equivalence.
+ * @param string $v Second path to test for equivalence.
* @return bool True if both paths are equivalent, i.e. reference the same
* entity in the filesystem.
* @task path
*/
public static function pathsAreEquivalent($u, $v) {
$u = self::resolvePath($u);
$v = self::resolvePath($v);
$real_u = realpath($u);
$real_v = realpath($v);
if ($real_u) {
$u = $real_u;
}
if ($real_v) {
$v = $real_v;
}
return ($u == $v);
}
public static function concatenatePaths(array $components) {
$components = implode(DIRECTORY_SEPARATOR, $components);
// Replace any extra sequences of directory separators with a single
// separator, so we don't end up with "path//to///thing.c".
$components = preg_replace(
'('.preg_quote(DIRECTORY_SEPARATOR).'{2,})',
DIRECTORY_SEPARATOR,
$components);
return $components;
}
/* -( Assert )------------------------------------------------------------- */
/**
* Assert that something (e.g., a file, directory, or symlink) exists at a
* specified location.
*
- * @param string Assert that this path exists.
+ * @param string $path Assert that this path exists.
* @return void
*
* @task assert
*/
public static function assertExists($path) {
if (self::pathExists($path)) {
return;
}
// Before we claim that the path doesn't exist, try to find a parent we
// don't have "+x" on. If we find one, tailor the error message so we don't
// say "does not exist" in cases where the path does exist, we just don't
// have permission to test its existence.
foreach (self::walkToRoot($path) as $parent) {
if (!self::pathExists($parent)) {
continue;
}
if (!is_dir($parent)) {
continue;
}
if (phutil_is_windows()) {
// Do nothing. On Windows, there's no obvious equivalent to the
// check below because "is_executable(...)" always appears to return
// "false" for any directory.
} else if (!is_executable($parent)) {
// On Linux, note that we don't need read permission ("+r") on parent
// directories to determine that a path exists, only execute ("+x").
throw new FilesystemException(
$path,
pht(
'Filesystem path "%s" can not be accessed because a parent '.
'directory ("%s") is not executable (the current process does '.
'not have "+x" permission).',
$path,
$parent));
}
}
throw new FilesystemException(
$path,
pht(
'Filesystem path "%s" does not exist.',
$path));
}
/**
* Assert that nothing exists at a specified location.
*
- * @param string Assert that this path does not exist.
+ * @param string $path Assert that this path does not exist.
* @return void
*
* @task assert
*/
public static function assertNotExists($path) {
if (file_exists($path) || is_link($path)) {
throw new FilesystemException(
$path,
pht("Path '%s' already exists!", $path));
}
}
/**
* Assert that a path represents a file, strictly (i.e., not a directory).
*
- * @param string Assert that this path is a file.
+ * @param string $path Assert that this path is a file.
* @return void
*
* @task assert
*/
public static function assertIsFile($path) {
if (!is_file($path)) {
throw new FilesystemException(
$path,
pht("Requested path '%s' is not a file.", $path));
}
}
/**
* Assert that a path represents a directory, strictly (i.e., not a file).
*
- * @param string Assert that this path is a directory.
+ * @param string $path Assert that this path is a directory.
* @return void
*
* @task assert
*/
public static function assertIsDirectory($path) {
if (!is_dir($path)) {
throw new FilesystemException(
$path,
pht("Requested path '%s' is not a directory.", $path));
}
}
/**
* Assert that a file or directory exists and is writable.
*
- * @param string Assert that this path is writable.
+ * @param string $path Assert that this path is writable.
* @return void
*
* @task assert
*/
public static function assertWritable($path) {
if (!is_writable($path)) {
throw new FilesystemException(
$path,
pht("Requested path '%s' is not writable.", $path));
}
}
/**
* Assert that a file or directory exists and is readable.
*
- * @param string Assert that this path is readable.
+ * @param string $path Assert that this path is readable.
* @return void
*
* @task assert
*/
public static function assertReadable($path) {
if (!is_readable($path)) {
throw new FilesystemException(
$path,
pht("Path '%s' is not readable.", $path));
}
}
}
diff --git a/src/filesystem/FilesystemException.php b/src/filesystem/FilesystemException.php
index cc464ff7..d1ba12ab 100644
--- a/src/filesystem/FilesystemException.php
+++ b/src/filesystem/FilesystemException.php
@@ -1,34 +1,34 @@
<?php
/**
* Exception thrown by Filesystem to indicate an error accessing the file
* system.
*/
final class FilesystemException extends Exception {
protected $path;
/**
* Create a new FilesystemException, providing a path and a message.
*
- * @param string Path that caused the failure.
- * @param string Description of the failure.
+ * @param string $path Path that caused the failure.
+ * @param string $message Description of the failure.
*/
public function __construct($path, $message) {
$this->path = $path;
parent::__construct($message);
}
/**
* Retrieve the path associated with the exception. Generally, this is
* something like a path that couldn't be read or written, or a path that
* was expected to exist but didn't.
*
* @return string Path associated with the exception.
*/
public function getPath() {
return $this->path;
}
}
diff --git a/src/filesystem/PhutilDeferredLog.php b/src/filesystem/PhutilDeferredLog.php
index 2193eb3c..e2d1ac35 100644
--- a/src/filesystem/PhutilDeferredLog.php
+++ b/src/filesystem/PhutilDeferredLog.php
@@ -1,246 +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 The file the entry should be written to, or null to
- * create a log object which does not write anywhere.
- * @param string The log entry format.
+ * @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 of variables to values.
+ * @param dict $map Map of variables to values.
* @return this
* @task log
*/
public function setData(array $map) {
$this->data = $map + $this->data;
return $this;
}
/**
* Get existing log data.
*
- * @param string Log data key.
- * @param wild Default to return if data does not exist.
+ * @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 where the entry should be written to, or null to
- * prevent writes.
+ * @param string|null $file File where the entry should be written to, or
+ * null to prevent writes.
* @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
* @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/PhutilFileLock.php b/src/filesystem/PhutilFileLock.php
index e8dd07f4..01e3ffc5 100644
--- a/src/filesystem/PhutilFileLock.php
+++ b/src/filesystem/PhutilFileLock.php
@@ -1,119 +1,119 @@
<?php
/**
* Wrapper around `flock()` for advisory filesystem locking. Usage is
* straightforward:
*
* $path = '/path/to/lock.file';
* $lock = PhutilFileLock::newForPath($path);
* $lock->lock();
*
* do_contentious_things();
*
* $lock->unlock();
*
* For more information on locks, see @{class:PhutilLock}.
*
* @task construct Constructing Locks
* @task impl Implementation
*/
final class PhutilFileLock extends PhutilLock {
private $lockfile;
private $handle;
/* -( Constructing Locks )------------------------------------------------- */
/**
* Create a new lock on a lockfile. The file need not exist yet.
*
- * @param string The lockfile to use.
+ * @param string $lockfile The lockfile to use.
* @return PhutilFileLock New lock object.
*
* @task construct
*/
public static function newForPath($lockfile) {
$lockfile = Filesystem::resolvePath($lockfile);
$name = 'file:'.$lockfile;
$lock = self::getLock($name);
if (!$lock) {
$lock = new PhutilFileLock($name);
$lock->lockfile = $lockfile;
self::registerLock($lock);
}
return $lock;
}
/* -( 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, this method throws. You can test the lock
* status with @{method:isLocked}.
*
- * @param float Seconds to block waiting for the lock.
+ * @param float $wait Seconds to block waiting for the lock.
* @return void
*
* @task lock
*/
protected function doLock($wait) {
$path = $this->lockfile;
$handle = @fopen($path, 'a+');
if (!$handle) {
throw new FilesystemException(
$path,
pht("Unable to open lock '%s' for writing!", $path));
}
$start_time = microtime(true);
do {
$would_block = null;
$ok = flock($handle, LOCK_EX | LOCK_NB, $would_block);
if ($ok) {
break;
} else {
usleep(10000);
}
} while ($wait && $wait > (microtime(true) - $start_time));
if (!$ok) {
fclose($handle);
throw new PhutilLockException($this->getName());
}
$this->handle = $handle;
}
/**
* Release the lock. Throws an exception on failure, e.g. if the lock is not
* currently held.
*
* @return void
*
* @task lock
*/
protected function doUnlock() {
$ok = flock($this->handle, LOCK_UN | LOCK_NB);
if (!$ok) {
throw new Exception(pht('Unable to unlock file!'));
}
$ok = fclose($this->handle);
if (!$ok) {
throw new Exception(pht('Unable to close file!'));
}
$this->handle = null;
}
}
diff --git a/src/filesystem/PhutilLock.php b/src/filesystem/PhutilLock.php
index f69d7544..3be495e1 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 Globally unique lock name.
+ * @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 Seconds to block waiting for the lock.
+ * @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 Lock name.
+ * @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 to register.
+ * @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 Seconds to block waiting for the lock. By default, do not
- * block.
+ * @param float $wait (optional) Seconds to block waiting for the lock. By
+ * default, do not block.
* @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
*
* @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 f905903c..ec493a7f 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 (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
+ * @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 (optional) Root directory to hold the file. If omitted, the
- * system temporary directory (often "/tmp") will be used by
- * default.
+ * @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 True to preserve the file after object destruction.
+ * @param bool $preserve True to preserve the file after object destruction.
* @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 74a33b69..4b030bd9 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 A one-byte delimiter character.
+ * @param string|null $character A one-byte delimiter character.
* @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/filesystem/linesofalarge/LinesOfALargeExecFuture.php b/src/filesystem/linesofalarge/LinesOfALargeExecFuture.php
index c54151f1..484eb502 100644
--- a/src/filesystem/linesofalarge/LinesOfALargeExecFuture.php
+++ b/src/filesystem/linesofalarge/LinesOfALargeExecFuture.php
@@ -1,119 +1,119 @@
<?php
/**
* Read the output stream of an @{class:ExecFuture} one line at a time. This
* abstraction allows you to process large inputs without holding them in
* memory. If you know your inputs fit in memory, it is generally more efficient
* (and certainly simpler) to read the entire input and `explode()` it. For
* more information, see @{class:LinesOfALarge}. See also
* @{class:LinesOfALargeFile} for a similar abstraction that works on files.
*
* $future = new ExecFuture('hg log ...');
* foreach (new LinesOfALargeExecFuture($future) as $line) {
* // ...
* }
*
* If the subprocess exits with an error, a @{class:CommandException} will be
* thrown.
*
* On destruction, this class terminates the subprocess if it has not already
* exited.
*
* @task construct Construction
* @task internals Internals
*/
final class LinesOfALargeExecFuture extends LinesOfALarge {
private $future;
private $didRewind;
/* -( Construction )------------------------------------------------------- */
/**
* To construct, pass an @{class:ExecFuture}.
*
- * @param ExecFuture Future to wrap.
+ * @param ExecFuture $future Future to wrap.
* @return void
* @task construct
*/
public function __construct(ExecFuture $future) {
$this->future = $future;
}
/* -( Internals )---------------------------------------------------------- */
/**
* On destruction, we terminate the subprocess if it hasn't exited already.
*
* @return void
* @task internals
*/
public function __destruct() {
if (!$this->future->isReady()) {
$this->future->resolveKill();
}
}
/**
* The PHP `foreach()` construct calls rewind() once, so we allow the first
* `rewind()`, without effect. Subsequent rewinds mean misuse.
*
* @return void
* @task internals
*/
protected function willRewind() {
if ($this->didRewind) {
throw new Exception(
pht(
"You can not reiterate over a %s object. The entire goal of the ".
"construct is to avoid keeping output in memory. What you are ".
"attempting to do is silly and doesn't make any sense.",
__CLASS__));
}
$this->didRewind = true;
}
/**
* Read more data from the subprocess.
*
* @return string Bytes read from stdout.
* @task internals
*/
protected function readMore() {
$future = $this->future;
while (true) {
// Read is nonblocking, so we need to sit in this loop waiting for input
// or we'll incorrectly signal EOF to the parent.
$stdout = $future->readStdout();
$future->discardStdoutBuffer();
if (strlen($stdout)) {
return $stdout;
}
// If we didn't read anything, we can exit the loop if the subprocess
// has exited.
if ($future->isReady()) {
// Throw if the process exits with a nonzero status code. This makes
// error handling simpler, and prevents us from returning part of a line
// if the process terminates mid-output.
$future->resolvex();
// Read and return anything that's left.
$stdout = $future->readStdout();
$future->discardStdoutBuffer();
return $stdout;
}
}
}
}
diff --git a/src/filesystem/linesofalarge/LinesOfALargeFile.php b/src/filesystem/linesofalarge/LinesOfALargeFile.php
index e04c4074..9f85d19b 100644
--- a/src/filesystem/linesofalarge/LinesOfALargeFile.php
+++ b/src/filesystem/linesofalarge/LinesOfALargeFile.php
@@ -1,106 +1,106 @@
<?php
/**
* Read the lines of a file, one at a time. This allows you to process large
* files without holding them in memory. In most cases, it is more efficient
* (and certainly simpler) to read the entire file and `explode()` it. For more
* information, see @{class:LinesOfALarge}. See also
* @{class:LinesOfALargeExecFuture}, for a similar abstraction that works on
* executed commands.
*
* foreach (new LinesOfALargeFile('/some/large/logfile.log') as $line) {
* // ...
* }
*
* If the file can not be read, a @{class:FilesystemException} is thrown.
*
* @task construct Construction
* @task internals Internals
*/
final class LinesOfALargeFile extends LinesOfALarge {
private $fileName;
private $handle;
/* -( Construction )------------------------------------------------------- */
/**
* To construct, pass the path to a file.
*
- * @param string File path to read.
+ * @param string $file_name File path to read.
* @return void
* @task construct
*/
public function __construct($file_name) {
$this->fileName = Filesystem::resolvePath((string)$file_name);
}
/* -( Internals )---------------------------------------------------------- */
/**
* Closes the file handle.
*
* @return void
* @task internals
*/
public function __destruct() {
$this->closeHandle();
}
/**
* Close the file handle, if it is open.
*
* @return $this
* @task internals
*/
private function closeHandle() {
if ($this->handle) {
fclose($this->handle);
$this->handle = null;
}
return $this;
}
/**
* Closes the file handle if it is open, and reopens it.
*
* @return void
* @task internals
*/
protected function willRewind() {
$this->closeHandle();
$this->handle = @fopen($this->fileName, 'r');
if (!$this->handle) {
throw new FilesystemException(
$this->fileName,
pht('Failed to open file!'));
}
}
/**
* Read the file chunk-by-chunk.
*
* @return string Next chunk of the file.
* @task internals
*/
protected function readMore() {
// NOTE: At least on OSX in reasonably normal test cases, increasing the
// size of this read has no impact on performance.
$more = @fread($this->handle, 2048);
if ($more === false) {
throw new FilesystemException(
$this->fileName,
pht('Failed to read file!'));
}
return $more;
}
}
diff --git a/src/future/FutureIterator.php b/src/future/FutureIterator.php
index bb464cd4..593d4942 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 List of @{class:Future}s to resolve.
+ * @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 @{class:Future} to add to iterator
+ * @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 Maximum number of seconds to block waiting on futures before
- * yielding null.
+ * @param float $interval Maximum number of seconds to block waiting on
+ * futures before yielding null.
* @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 Maximum number of simultaneous jobs allowed.
+ * @param int $max Maximum number of simultaneous jobs allowed.
* @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 List of sockets expected to become readable.
- * @param list List of sockets expected to become writable.
- * @param float Timeout, in seconds.
+ * @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 ddf9e515..5c25fc97 100644
--- a/src/future/exec/ExecFuture.php
+++ b/src/future/exec/ExecFuture.php
@@ -1,1050 +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 Maximum size of the stdout read buffer.
+ * @param int $limit Maximum size of the stdout read buffer.
* @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 Maximum size of the stderr read buffer.
+ * @param int $limit Maximum size of the stderr read buffer.
* @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 Maximum buffer size, or `null` for unlimited.
+ * @param int|null $read_buffer_size Maximum buffer size, or `null` for
+ * unlimited.
* @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 to write.
- * @param bool 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.
+ * @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
* @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
* @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 Maximum number of seconds this command may execute for before
- * it is signaled.
+ * @param int $seconds Maximum number of seconds this command may execute for
+ * before it is signaled.
* @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 to read from.
- * @param int Maximum number of bytes to return from $stream. If
+ * @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 Human-readable description of stream, for exception
- * message.
- * @param int Maximum number of bytes to read.
+ * @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 13e6977d..b4c490b9 100644
--- a/src/future/exec/PhutilExecutableFuture.php
+++ b/src/future/exec/PhutilExecutableFuture.php
@@ -1,231 +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> Dictionary of environmental variables.
- * @param bool Optionally, pass `true` to replace the existing environment.
+ * @param map<string, string> $env Dictionary of environmental variables.
+ * @param bool $wipe_process_env (optional) Pass `true` to replace the
+ * existing environment.
* @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 Environmental variable name.
- * @param string|null New value, or null to remove this variable.
+ * @param string $key Environmental variable name.
+ * @param string|null $value New value, or null to remove this variable.
* @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 Directory to execute the subprocess in.
+ * @param string $cwd Directory to execute the subprocess in.
* @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/exec/execx.php b/src/future/exec/execx.php
index ee920aff..5c10f289 100644
--- a/src/future/exec/execx.php
+++ b/src/future/exec/execx.php
@@ -1,107 +1,107 @@
<?php
/**
* Execute a command and capture stdout and stderr. If the command exits with
* a nonzero error code, a @{class:CommandException} will be thrown. If you need
* to manually handle error conditions, use @{function:exec_manual}.
*
* list ($stdout, $stderr) = execx('ls %s', $file);
*
- * @param string sprintf()-style command pattern to execute.
+ * @param string $cmd sprintf()-style command pattern to execute.
* @param ... Arguments to sprintf pattern.
* @return array List of stdout and stderr.
*/
function execx($cmd /* , ... */) {
$args = func_get_args();
$future = newv('ExecFuture', $args);
return $future->resolvex();
}
/**
* Execute a command and capture stdout, stderr, and the return value.
*
* list ($err, $stdout, $stderr) = exec_manual('ls %s', $file);
*
* When invoking this function, you must manually handle the error condition.
* Error flows can often be simplified by using @{function:execx} instead,
* which throws an exception when it encounters an error.
*
- * @param string sprintf()-style command pattern to execute.
+ * @param string $cmd sprintf()-style command pattern to execute.
* @param ... Arguments to sprintf pattern.
* @return array List of return code, stdout, and stderr.
*/
function exec_manual($cmd /* , ... */) {
$args = func_get_args();
$ef = newv('ExecFuture', $args);
return $ef->resolve();
}
/**
* Wrapper for @{class:PhutilExecPassthru}.
*
- * @param string sprintf()-style command pattern to execute.
+ * @param string $cmd sprintf()-style command pattern to execute.
* @param ... Arguments to sprintf pattern.
* @return int Return code.
*/
function phutil_passthru($cmd /* , ... */) {
$args = func_get_args();
return newv('PhutilExecPassthru', $args)->resolve();
}
/**
* Return a human-readable signal name (like "SIGINT" or "SIGKILL") for a given
* signal number.
*
- * @param int Signal number.
+ * @param int $signo Signal number.
* @return string Human-readable signal name.
*/
function phutil_get_signal_name($signo) {
// These aren't always defined; try our best to look up the signal name.
$constant_names = array(
'SIGHUP',
'SIGINT',
'SIGQUIT',
'SIGILL',
'SIGTRAP',
'SIGABRT',
'SIGIOT',
'SIGBUS',
'SIGFPE',
'SIGUSR1',
'SIGSEGV',
'SIGUSR2',
'SIGPIPE',
'SIGALRM',
'SIGTERM',
'SIGSTKFLT',
'SIGCLD',
'SIGCHLD',
'SIGCONT',
'SIGTSTP',
'SIGTTIN',
'SIGTTOU',
'SIGURG',
'SIGXCPU',
'SIGXFSZ',
'SIGVTALRM',
'SIGPROF',
'SIGWINCH',
'SIGPOLL',
'SIGIO',
'SIGPWR',
'SIGSYS',
'SIGBABY',
);
$signal_names = array();
foreach ($constant_names as $constant) {
if (defined($constant)) {
$signal_names[constant($constant)] = $constant;
}
}
return idx($signal_names, $signo);
}
diff --git a/src/future/http/BaseHTTPFuture.php b/src/future/http/BaseHTTPFuture.php
index 1def346b..a9c6bf93 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 Fully-qualified URI to send a request to.
- * @param mixed 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'.
+ * @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 Maximum timeout, in seconds.
+ * @param float $timeout Maximum timeout, in seconds.
* @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 HTTP method name.
+ * @param string $method HTTP method name.
* @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 to send the request to.
+ * @param string $uri URI to send the request to.
* @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 to send with the request.
+ * @param mixed $data Data to send with the request.
* @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 Header name, like "Accept-Language".
- * @param string Header value, like "en-us".
+ * @param string $name Header name, like "Accept-Language".
+ * @param string $value Header value, like "en-us".
* @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 Optional filter, which selects only headers with that
- * name if provided.
+ * @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 List of expected HTTP status codes.
+ * @param array|null $status_codes List of expected HTTP status codes.
*
* @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 to authenticate with.
- * @param PhutilOpaqueEnvelope Password to authenticate with.
+ * @param string $username Username to authenticate with.
+ * @param PhutilOpaqueEnvelope $password Password to authenticate with.
* @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 HTTP response.
+ * @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 Raw HTTP headers.
+ * @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 List of headers from `resolve()`.
- * @param string Case insensitive header name.
+ * @param list $headers List of headers from `resolve()`.
+ * @param string $search Case insensitive header name.
* @return string 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 48824fb1..22f78ea5 100644
--- a/src/future/http/HTTPSFuture.php
+++ b/src/future/http/HTTPSFuture.php
@@ -1,878 +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 The multi-line, possibly lengthy, SSL certificate to use.
+ * @param string $certificate The multi-line, possibly lengthy, SSL
+ * certificate to use.
* @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 The path to a valid SSL certificate for this session
+ * @param string $path The path to a valid SSL certificate for this session
* @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 true to follow any Location header present in the response,
- * false to return the request directly
+ * @param boolean $follow true to follow any Location header present in the
+ * response, false to return the request directly
* @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 The path to a valid SSL certificate
+ * @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 The certificate
+ * @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
- * @param float
+ * @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 HTTP parameter name.
- * @param string File content.
- * @param string File name.
- * @param string File mime type.
+ * @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
*/
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
*/
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 Possibly dangerous string.
- * @param bool True if this string is being used as part of a query string.
+ * @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.
+ * @param string $raw_body Raw body.
* @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/ArcanistHgClientChannel.php b/src/hgdaemon/ArcanistHgClientChannel.php
index 82ce3002..f1559e17 100644
--- a/src/hgdaemon/ArcanistHgClientChannel.php
+++ b/src/hgdaemon/ArcanistHgClientChannel.php
@@ -1,170 +1,170 @@
<?php
/**
* Channel to a Mercurial "cmdserver" client. For a detailed description of the
* "cmdserver" protocol, see @{class:ArcanistHgServerChannel}. This channel
* implements the other half of the protocol: it decodes messages from the
* client and encodes messages from the server.
*
* Because the proxy server speaks the exact same protocol that Mercurial
* does and fully decodes both sides of the protocol, we need this half of the
* decode/encode to talk to clients. Without it, we wouldn't be able to
* determine when a client request had completed and was ready for transmission
* to the Mercurial server.
*
* (Technically, we could get away without re-encoding messages from the
* server, but the serialization is not complicated and having a general
* implementation of encoded/decode for both the client and server dialects
* seemed useful.)
*
* @task protocol Protocol Implementation
*/
final class ArcanistHgClientChannel extends PhutilProtocolChannel {
const MODE_COMMAND = 'command';
const MODE_LENGTH = 'length';
const MODE_ARGUMENTS = 'arguments';
private $command;
private $byteLengthOfNextChunk;
private $buf = '';
private $mode = self::MODE_COMMAND;
/* -( Protocol Implementation )-------------------------------------------- */
/**
* Encode a message for transmission to the client. The message should be
* a pair with the channel name and the a block of data, like this:
*
* array('o', '<some data...>');
*
* We encode it like this:
*
* o
* 1234 # Length, as a 4-byte unsigned long.
* <data: 1234 bytes>
*
* For a detailed description of the cmdserver protocol, see
* @{class:ArcanistHgServerChannel}.
*
- * @param pair<string,string> The <channel, data> pair to encode.
+ * @param pair<string,string> $argv The <channel, data> pair to encode.
* @return string Encoded string for transmission to the client.
*
* @task protocol
*/
protected function encodeMessage($argv) {
if (!is_array($argv) || count($argv) !== 2) {
throw new Exception(pht('Message should be %s.', '<channel, data>'));
}
$channel = head($argv);
$data = last($argv);
$len = strlen($data);
$len = pack('N', $len);
return "{$channel}{$len}{$data}";
}
/**
* Decode a message received from the client. The message looks like this:
*
* runcommand\n
* 8 # Length, as a 4-byte unsigned long.
* log\0
* -l\0
* 5
*
* We decode it into a list in PHP, which looks like this:
*
* array(
* 'runcommand',
* 'log',
* '-l',
* '5',
* );
*
- * @param string Bytes from the server.
+ * @param string $data Bytes from the server.
* @return list<list<string>> Zero or more complete commands.
*
* @task protocol
*/
protected function decodeStream($data) {
$this->buf .= $data;
// The first part is terminated by "\n", so we don't always know how many
// bytes we need to look for. This makes parsing a bit of a pain.
$messages = array();
do {
$continue_parsing = false;
switch ($this->mode) {
case self::MODE_COMMAND:
// We're looking for "\n", which indicates the end of the command
// name, like "runcommand". Next, we'll expect a length.
$pos = strpos($this->buf, "\n");
if ($pos === false) {
break;
}
$this->command = substr($this->buf, 0, $pos);
$this->buf = substr($this->buf, $pos + 1);
$this->mode = self::MODE_LENGTH;
$continue_parsing = true;
break;
case self::MODE_LENGTH:
// We're looking for a byte length, as a 4-byte big-endian unsigned
// integer. Next, we'll expect that many bytes of data.
if (strlen($this->buf) < 4) {
break;
}
$len = substr($this->buf, 0, 4);
$len = unpack('N', $len);
$len = head($len);
$this->buf = substr($this->buf, 4);
$this->mode = self::MODE_ARGUMENTS;
$this->byteLengthOfNextChunk = $len;
$continue_parsing = true;
break;
case self::MODE_ARGUMENTS:
// We're looking for the data itself, which is a block of bytes
// of the given length. These are arguments delimited by "\0". Next
// we'll expect another command.
if (strlen($this->buf) < $this->byteLengthOfNextChunk) {
break;
}
$data = substr($this->buf, 0, $this->byteLengthOfNextChunk);
$this->buf = substr($this->buf, $this->byteLengthOfNextChunk);
$message = array_merge(array($this->command), explode("\0", $data));
$this->mode = self::MODE_COMMAND;
$this->command = null;
$this->byteLengthOfNextChunk = null;
$messages[] = $message;
$continue_parsing = true;
break;
}
} while ($continue_parsing);
return $messages;
}
}
diff --git a/src/hgdaemon/ArcanistHgProxyClient.php b/src/hgdaemon/ArcanistHgProxyClient.php
index 6aa00e74..af9d8db9 100644
--- a/src/hgdaemon/ArcanistHgProxyClient.php
+++ b/src/hgdaemon/ArcanistHgProxyClient.php
@@ -1,200 +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 Path to a Mercurial working copy.
+ * @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 True to skip the "capabilities" message.
+ * @param bool $skip True to skip the "capabilities" message.
* @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> A list of command arguments, like "log", "-l", "5".
+ * @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 d0d40a9b..b39a15bc 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 Path to a Mercurial working copy.
+ * @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 True to disable status messages.
+ * @param bool $quiet True to disable status messages.
* @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 Client limit, or 0 to disable limit.
+ * @param int $limit Client limit, or 0 to disable limit.
* @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 Idle limit, or 0 to disable limit.
+ * @param int $limit Idle limit, or 0 to disable limit.
* @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 True to skip the "capabilities" message.
+ * @param bool $skip True to skip the "capabilities" message.
* @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 True to run in the foreground.
+ * @param bool $do_not_daemonize True to run in the foreground.
* @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 The client to update.
- * @param ArcanistHgServerChannel The Mercurial server.
+ * @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/hgdaemon/ArcanistHgServerChannel.php b/src/hgdaemon/ArcanistHgServerChannel.php
index 22f6b4ab..ccf68834 100644
--- a/src/hgdaemon/ArcanistHgServerChannel.php
+++ b/src/hgdaemon/ArcanistHgServerChannel.php
@@ -1,179 +1,179 @@
<?php
/**
* Channel to a Mercurial "cmdserver" server. Messages sent to the server
* look like this:
*
* runcommand\n
* 8 # Length, as a 4-byte unsigned long.
* log\0
* -l\0
* 5
*
* In PHP, the format of these messages is an array of arguments:
*
* array(
* 'runcommand',
* 'log',
* '-l',
* '5',
* );
*
* The server replies with messages that look like this:
*
* o
* 1234 # Length, as a 4-byte unsigned long.
* <data: 1234 bytes>
*
* The first character in a message from the server is the "channel". Mercurial
* channels have nothing to do with Phutil channels; they are more similar to
* stdout/stderr. Mercurial has four primary channels:
*
* 'o'utput, like stdout
* 'e'rror, like stderr
* 'r'esult, like return codes
* 'd'ebug, like an external log file
*
* In PHP, the format of these messages is a pair, with the channel and then
* the data:
*
* array('o', '<data...>');
*
* In general, we send "runcommand" requests, and the server responds with
* a series of messages on the "output" channel and then a single response
* on the "result" channel to indicate that output is complete.
*
* @task protocol Protocol Implementation
*/
final class ArcanistHgServerChannel extends PhutilProtocolChannel {
const MODE_CHANNEL = 'channel';
const MODE_LENGTH = 'length';
const MODE_BLOCK = 'block';
private $mode = self::MODE_CHANNEL;
private $byteLengthOfNextChunk = 1;
private $buf = '';
private $outputChannel;
/* -( Protocol Implementation )-------------------------------------------- */
/**
* Encode a message for transmission to the server. The message should be
* formatted as an array, like this:
*
* array(
* 'runcommand',
* 'log',
* '-l',
* '5',
* );
*
*
* We will return the cmdserver version of this:
*
* runcommand\n
* 8 # Length, as a 4-byte unsigned long.
* log\0
* -l\0
* 5
*
- * @param list<string> List of command arguments.
+ * @param list<string> $argv List of command arguments.
* @return string Encoded string for transmission to the server.
*
* @task protocol
*/
protected function encodeMessage($argv) {
if (!is_array($argv)) {
throw new Exception(
pht('Message to Mercurial server should be an array.'));
}
$command = head($argv);
$args = array_slice($argv, 1);
$args = implode("\0", $args);
$len = strlen($args);
$len = pack('N', $len);
return "{$command}\n{$len}{$args}";
}
/**
* Decode a message received from the server. The message looks like this:
*
* o
* 1234 # Length, as a 4-byte unsigned long.
* <data: 1234 bytes>
*
* ...where 'o' is the "channel" the message is being sent over.
*
* We decode into a pair in PHP, which looks like this:
*
* array('o', '<data...>');
*
- * @param string Bytes from the server.
+ * @param string $data Bytes from the server.
* @return list<pair<string,string>> Zero or more complete messages.
*
* @task protocol
*/
protected function decodeStream($data) {
$this->buf .= $data;
// We always know how long the next chunk is, so this parser is fairly
// easy to implement.
$messages = array();
while ($this->byteLengthOfNextChunk <= strlen($this->buf)) {
$chunk = substr($this->buf, 0, $this->byteLengthOfNextChunk);
$this->buf = substr($this->buf, $this->byteLengthOfNextChunk);
switch ($this->mode) {
case self::MODE_CHANNEL:
// We've received the channel name, one of 'o', 'e', 'r' or 'd' for
// 'output', 'error', 'result' or 'debug' respectively. This is a
// single byte long. Next, we'll expect a length.
$this->outputChannel = $chunk;
$this->byteLengthOfNextChunk = 4;
$this->mode = self::MODE_LENGTH;
break;
case self::MODE_LENGTH:
// We've received the length of the data, as a 4-byte big-endian
// unsigned integer. Next, we'll expect the data itself.
$this->byteLengthOfNextChunk = head(unpack('N', $chunk));
$this->mode = self::MODE_BLOCK;
break;
case self::MODE_BLOCK:
// We've received the data itself, which is a block of bytes of the
// given length. We produce a message from the channel and the data
// and return it. Next, we expect another channel name.
$message = array($this->outputChannel, $chunk);
$this->byteLengthOfNextChunk = 1;
$this->mode = self::MODE_CHANNEL;
$this->outputChannel = null;
$messages[] = $message;
break;
}
}
// Return zero or more messages, which might look something like this:
//
// array(
// array('o', '<...>'),
// array('o', '<...>'),
// array('r', '<...>'),
// );
return $messages;
}
}
diff --git a/src/init/lib/PhutilLibraryConflictException.php b/src/init/lib/PhutilLibraryConflictException.php
index 4a9440ed..f7ca4bf6 100644
--- a/src/init/lib/PhutilLibraryConflictException.php
+++ b/src/init/lib/PhutilLibraryConflictException.php
@@ -1,86 +1,86 @@
<?php
/**
* Thrown when you attempt to load two different copies of a library with the
* same name. Trying to load the second copy of the library will trigger this,
* and the library will not be loaded.
*
* This means you've either done something silly (like tried to explicitly load
* two different versions of the same library into the same program -- this
* won't work because they'll have namespace conflicts), or your configuration
* might have some problems which caused two parts of your program to try to
* load the same library but end up loading different copies of it, or there
* may be some subtle issue like running 'arc' in a different Arcanist working
* directory. (Some bootstrapping workflows like that which run low-level
* library components on other copies of themselves are expected to fail.)
*
* To resolve this, you need to make sure your program loads no more than one
* copy of each libphutil library, but exactly how you approach this depends on
* why it's happening in the first place.
*
* @task info Getting Exception Information
* @task construct Creating Library Conflict Exceptions
*/
final class PhutilLibraryConflictException extends Exception {
private $library;
private $oldPath;
private $newPath;
/**
* Create a new library conflict exception.
*
- * @param string The name of the library which conflicts with an existing
- * library.
- * @param string The path of the already-loaded library.
- * @param string The path of the attempting-to-load library.
+ * @param string $library The name of the library which conflicts with an
+ * existing library.
+ * @param string $old_path The path of the already-loaded library.
+ * @param string $new_path The path of the attempting-to-load library.
*
* @task construct
*/
public function __construct($library, $old_path, $new_path) {
$this->library = $library;
$this->oldPath = $old_path;
$this->newPath = $new_path;
parent::__construct(pht(
"Library conflict! The library '%s' has already been loaded (from '%s') ".
"but is now being loaded again from a new location ('%s'). You can not ".
"load multiple copies of the same library into a program.",
$library,
$old_path,
$new_path));
}
/**
* Retrieve the name of the library in conflict.
*
* @return string The name of the library which conflicts with an existing
* library.
* @task info
*/
public function getLibrary() {
return $this->library;
}
/**
* Get the path to the library which has already been loaded earlier in the
* program's execution.
*
* @return string The path of the already-loaded library.
* @task info
*/
public function getOldPath() {
return $this->oldPath;
}
/**
* Get the path to the library which is causing this conflict.
*
* @return string The path of the attempting-to-load library.
* @task info
*/
public function getNewPath() {
return $this->newPath;
}
}
diff --git a/src/internationalization/PhutilLocale.php b/src/internationalization/PhutilLocale.php
index 5ed03a92..402d0970 100644
--- a/src/internationalization/PhutilLocale.php
+++ b/src/internationalization/PhutilLocale.php
@@ -1,250 +1,250 @@
<?php
/**
* Defines a locale for translations.
*
* Examples might include "English (US)" or "Japanese".
*/
abstract class PhutilLocale extends Phobject {
/**
* Get the local identifier code, like "en_US".
*
* @return string Locale identifier code.
*/
abstract public function getLocaleCode();
/**
* Get the human-readable locale name, like "English (US)".
*
* @return string Human-readable locale name.
*/
abstract public function getLocaleName();
/**
* Set a fallback locale which can be used as a default if this locale is
* missing translations.
*
* For locales like "English (Great Britain)", missing translations can be
* sourced from "English (US)".
*
* Languages with no other fallback use en_US because that's better
* than proto-English for untranslated strings.
*
* @return string|null Locale code of fallback locale, or null if there is
* no fallback locale.
*/
public function getFallbackLocaleCode() {
return 'en_US';
}
/**
* Select a gender variant for this locale. By default, locales use a simple
* rule with two gender variants, listed in "<male, female>" order.
*
- * @param const `PhutilPerson` gender constant.
- * @param list<wild> List of variants.
+ * @param const $variant `PhutilPerson` gender constant.
+ * @param list<wild> $translations List of variants.
* @return string Variant for use.
*/
public function selectGenderVariant($variant, array $translations) {
if ($variant == PhutilPerson::GENDER_FEMININE) {
return end($translations);
} else {
return reset($translations);
}
}
/**
* Select a plural variant for this locale. By default, locales use a simple
* rule with two plural variants, listed in "<singular, plural>" order.
*
- * @param int Plurality of the value.
- * @param list<wild> List of variants.
+ * @param int $variant Plurality of the value.
+ * @param list<wild> $translations List of variants.
* @return string Variant for use.
*/
public function selectPluralVariant($variant, array $translations) {
if ($variant == 1) {
return reset($translations);
} else {
return end($translations);
}
}
/**
* Flags a locale as silly, like "English (Pirate)".
*
* These locales are fun but disastrously inappropriate for serious
* businesses.
*
* @return bool True if this locale is silly.
*/
public function isSillyLocale() {
return false;
}
/**
* Flags a locale as a testing locale, like "English (US, ALL CAPS)". These
* locales are useful for translation development, but not for normal users.
*
* @return bool True if this is a locale for testing or development.
*/
public function isTestLocale() {
return false;
}
/**
* Indicates that the translator should post-process translations in this
* locale by calling @{method:didTranslateString}.
*
* Doing this incurs a performance penalty, and is not useful for most
* languages. However, it can be used to implement test translations like
* "English (US, ALL CAPS)".
*
* @return bool True to postprocess strings.
*/
public function shouldPostProcessTranslations() {
return false;
}
/**
* Callback for post-processing translations.
*
* By default, this callback is not invoked. To activate it, return `true`
* from @{method:shouldPostProcessTranslations}. Activating this callback
* incurs a performance penalty.
*
- * @param string The raw input pattern.
- * @param string The selected translation pattern.
- * @param list<wild> The raw input arguments.
- * @param string The translated string.
+ * @param string $raw_pattern The raw input pattern.
+ * @param string $translated_pattern The selected translation pattern.
+ * @param list<wild> $args The raw input arguments.
+ * @param string $result_text The translated string.
* @return string Post-processed translation string.
*/
public function didTranslateString(
$raw_pattern,
$translated_pattern,
array $args,
$result_text) {
return $result_text;
}
/**
* Load all available locales.
*
* @return map<string, PhutilLocale> Map from codes to locale objects.
*/
public static function loadAllLocales() {
static $locales;
if ($locales === null) {
$objects = id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->execute();
$locale_map = array();
foreach ($objects as $object) {
$locale_code = $object->getLocaleCode();
if (empty($locale_map[$locale_code])) {
$locale_map[$locale_code] = $object;
} else {
throw new Exception(
pht(
'Two subclasses of "%s" ("%s" and "%s") define '.
'locales with the same locale code ("%s"). Each locale must '.
'have a unique locale code.',
__CLASS__,
get_class($object),
get_class($locale_map[$locale_code]),
$locale_code));
}
}
foreach ($locale_map as $locale_code => $locale) {
$fallback_code = $locale->getFallbackLocaleCode();
if ($fallback_code !== null) {
if (empty($locale_map[$fallback_code])) {
throw new Exception(
pht(
'The locale "%s" has an invalid fallback locale code ("%s"). '.
'No locale class exists which defines this locale.',
get_class($locale),
$fallback_code));
}
}
}
foreach ($locale_map as $locale_code => $locale) {
$seen = array($locale_code => get_class($locale));
self::checkLocaleFallback($locale_map, $locale, $seen);
}
$locales = $locale_map;
}
return $locales;
}
/**
* Load a specific locale using a locale code.
*
- * @param string Locale code.
+ * @param string $locale_code Locale code.
* @return PhutilLocale Locale object.
*/
public static function loadLocale($locale_code) {
$all_locales = self::loadAllLocales();
$locale = idx($all_locales, $locale_code);
if (!$locale) {
throw new Exception(
pht(
'There is no locale with the locale code "%s".',
$locale_code));
}
return $locale;
}
/**
* Recursively check locale fallbacks for cycles.
*
- * @param map<string, PhutilLocale> Map of locales.
- * @param PhutilLocale Current locale.
- * @param map<string, string> Map of visited locales.
+ * @param map<string, PhutilLocale> $map Map of locales.
+ * @param PhutilLocale $locale Current locale.
+ * @param map<string, string> $seen Map of visited locales.
* @return void
*/
private static function checkLocaleFallback(
array $map,
PhutilLocale $locale,
array $seen) {
$fallback_code = $locale->getFallbackLocaleCode();
if ($fallback_code === null) {
return;
}
if (isset($seen[$fallback_code])) {
$seen[] = get_class($locale);
$seen[] = pht('...');
throw new Exception(
pht(
'Locale "%s" is part of a cycle of locales which fall back on '.
'one another in a loop (%s). Locales which fall back on other '.
'locales must not loop.',
get_class($locale),
implode(' -> ', $seen)));
}
$seen[$fallback_code] = get_class($locale);
self::checkLocaleFallback($map, $map[$fallback_code], $seen);
}
}
diff --git a/src/internationalization/PhutilTranslation.php b/src/internationalization/PhutilTranslation.php
index 7acac136..7b44b497 100644
--- a/src/internationalization/PhutilTranslation.php
+++ b/src/internationalization/PhutilTranslation.php
@@ -1,85 +1,85 @@
<?php
abstract class PhutilTranslation extends Phobject {
/**
* Get the locale code which this class translates text for, like
* "en_GB".
*
* This should correspond to a valid subclass of @{class:PhutilLocale}.
*
* @return string Locale code for this translation.
*/
abstract public function getLocaleCode();
/**
* Return a map of all translations.
*
* @return map<string, wild> Map of raw strings to translations.
*/
abstract protected function getTranslations();
/**
* Return a filtered map of all strings in this translation.
*
* Filters out empty/placeholder translations.
*
* @return map<string, wild> Map of raw strings to translations.
*/
final public function getFilteredTranslations() {
$translations = $this->getTranslations();
foreach ($translations as $key => $translation) {
if ($translation === null) {
unset($translations[$key]);
}
}
return $translations;
}
/**
* Load all available translation objects.
*
* @return list<PhutilTranslation> List of available translation sources.
*/
public static function loadAllTranslations() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->execute();
}
/**
* Load the complete translation map for a locale.
*
* This will compile primary and fallback translations into a single
* translation map.
*
- * @param string Locale code, like "en_US".
+ * @param string $locale_code Locale code, like "en_US".
* @return map<string, wild> Map of all avialable translations.
*/
public static function getTranslationMapForLocale($locale_code) {
$locale = PhutilLocale::loadLocale($locale_code);
$translations = self::loadAllTranslations();
$results = array();
foreach ($translations as $translation) {
if ($translation->getLocaleCode() == $locale_code) {
$results += $translation->getFilteredTranslations();
}
}
$fallback_code = $locale->getFallbackLocaleCode();
if ($fallback_code !== null) {
$results += self::getTranslationMapForLocale($fallback_code);
}
return $results;
}
}
diff --git a/src/internationalization/PhutilTranslator.php b/src/internationalization/PhutilTranslator.php
index c2b0984a..46ba0dc9 100644
--- a/src/internationalization/PhutilTranslator.php
+++ b/src/internationalization/PhutilTranslator.php
@@ -1,275 +1,275 @@
<?php
final class PhutilTranslator extends Phobject {
private static $instance;
private $locale;
private $localeCode;
private $shouldPostProcess;
private $willTranslateCallback;
private $translations = array();
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new PhutilTranslator();
}
return self::$instance;
}
public static function setInstance(PhutilTranslator $instance) {
self::$instance = $instance;
}
public function setLocale(PhutilLocale $locale) {
$this->locale = $locale;
$this->localeCode = $locale->getLocaleCode();
$this->shouldPostProcess = $locale->shouldPostProcessTranslations();
return $this;
}
public function setWillTranslateCallback($callback) {
$this->willTranslateCallback = $callback;
return $this;
}
public function getWillTranslateCallback() {
return $this->willTranslateCallback;
}
/**
* Add translations which will be later used by @{method:translate}.
* The parameter is an array of strings (for simple translations) or arrays
* (for translations with variants). The number of items in the array is
* language specific. It is `array($singular, $plural)` for English.
*
* array(
* 'color' => 'colour',
* '%d beer(s)' => array('%d beer', '%d beers'),
* );
*
* The arrays can be nested for strings with more variant parts:
*
* array(
* '%d char(s) on %d row(s)' => array(
* array('%d char on %d row', '%d char on %d rows'),
* array('%d chars on %d row', '%d chars on %d rows'),
* ),
* );
*
* The translation should have the same placeholders as originals. Swapping
* parameter order is possible:
*
* array(
* '%s owns %s.' => '%2$s is owned by %1$s.',
* );
*
- * @param array Identifier in key, translation in value.
+ * @param array $translations Identifier in key, translation in value.
* @return PhutilTranslator Provides fluent interface.
*/
public function setTranslations(array $translations) {
$this->translations = $translations;
return $this;
}
/**
* @phutil-external-symbol class PhutilSafeHTML
* @phutil-external-symbol class PhutilSafeHTMLProducerInterface
* @phutil-external-symbol function phutil_escape_html
* @phutil-external-symbol function phutil_safe_html
*/
public function translate($text /* , ... */) {
$args = func_get_args();
if ($this->willTranslateCallback) {
call_user_func_array($this->willTranslateCallback, $args);
}
if (isset($this->translations[$text])) {
$translation = $this->translations[$text];
} else {
$translation = $text;
}
while (is_array($translation)) {
$arg = next($args);
$translation = $this->chooseVariant($translation, $arg);
if ($translation === null) {
$pos = key($args);
if (is_object($arg)) {
$kind = get_class($arg);
} else {
$kind = gettype($arg);
}
return sprintf(
'[Invalid Translation!] The "%s" language data offers variant '.
'translations for the plurality or gender of argument %s, but '.
'the value for that argument is not an integer, PhutilNumber, or '.
'PhutilPerson (it is a value of type "%s"). Raw input: <%s>.',
$this->localeCode,
$pos,
$kind,
$text);
}
}
array_shift($args);
foreach ($args as $k => $arg) {
if ($arg instanceof PhutilNumber) {
$args[$k] = $this->formatNumber($arg->getNumber(), $arg->getDecimals());
}
}
// Check if any arguments are PhutilSafeHTML. If they are, we will apply
// any escaping necessary and output HTML.
$is_html = false;
foreach ($args as $arg) {
if ($arg instanceof PhutilSafeHTML ||
$arg instanceof PhutilSafeHTMLProducerInterface) {
$is_html = true;
break;
}
}
if ($is_html) {
foreach ($args as $k => $arg) {
$args[$k] = (string)phutil_escape_html($arg);
}
}
$result = vsprintf($translation, $args);
if ($result === false) {
// If vsprintf() fails (often because the translated string references
// too many parameters), show the bad template with a note instead of
// returning an empty string. This makes it easier to figure out what
// went wrong and fix it.
$result = pht('[Invalid Translation!] %s', $translation);
}
if ($this->shouldPostProcess) {
$result = $this->locale->didTranslateString(
$text,
$translation,
$args,
$result);
}
if ($is_html) {
$result = phutil_safe_html($result);
}
return $result;
}
private function chooseVariant(array $translations, $variant) {
if (count($translations) == 1) {
// If we only have one variant, we can select it directly.
return reset($translations);
}
if ($variant instanceof PhutilNumber) {
$is_gender = false;
$variant = $variant->getNumber();
} else if ($variant instanceof PhutilPerson) {
$is_gender = true;
$variant = $variant->getGender();
} else if (is_int($variant)) {
$is_gender = false;
} else {
return null;
}
if ($is_gender) {
return $this->locale->selectGenderVariant($variant, $translations);
} else {
// NOTE: This is a microoptimization which slightly improves performance
// for common languages with simple plural rules. Languages do not need
// to be added here even if they use the simple rules. The benefit of
// inclusion here is small, on the order of 5%.
static $simple_plural = array(
'en_US' => true,
'en_GB' => true,
'en_ES' => true,
'ko_KR' => true,
);
if (isset($simple_plural[$this->localeCode])) {
if ($variant == 1) {
return reset($translations);
} else {
return end($translations);
}
} else {
return $this->locale->selectPluralVariant($variant, $translations);
}
}
}
/**
* Translate date formatted by `$date->format()`.
*
- * @param string Format accepted by `DateTime::format()`.
- * @param DateTime
+ * @param string $format Format accepted by `DateTime::format()`.
+ * @param DateTime $date
* @return string Formatted and translated date.
*/
public function translateDate($format, DateTime $date) {
static $format_cache = array();
if (!isset($format_cache[$format])) {
$translatable = 'DlSFMaA';
preg_match_all(
'/['.$translatable.']|(\\\\.|[^'.$translatable.'])+/',
$format,
$format_cache[$format],
PREG_SET_ORDER);
}
$parts = array();
foreach ($format_cache[$format] as $match) {
$part = $date->format($match[0]);
if (!isset($match[1])) {
$part = $this->translate($part);
}
$parts[] = $part;
}
return implode('', $parts);
}
/**
* Format number with grouped thousands and optional decimal part. Requires
* translations of '.' (decimal point) and ',' (thousands separator). Both
* these translations must be 1 byte long with PHP < 5.4.0.
*
- * @param float
- * @param int
+ * @param float $number
+ * @param int $decimals (optional)
* @return string
*/
public function formatNumber($number, $decimals = 0) {
return number_format(
$number,
$decimals,
$this->translate('.'),
$this->translate(','));
}
public function validateTranslation($original, $translation) {
$pattern = '/<(\S[^>]*>?)?|&(\S[^;]*;?)?/i';
$original_matches = null;
$translation_matches = null;
preg_match_all($pattern, $original, $original_matches);
preg_match_all($pattern, $translation, $translation_matches);
sort($original_matches[0]);
sort($translation_matches[0]);
if ($original_matches[0] !== $translation_matches[0]) {
return false;
}
return true;
}
}
diff --git a/src/internationalization/pht.php b/src/internationalization/pht.php
index 1d9a2f7b..5062fafc 100644
--- a/src/internationalization/pht.php
+++ b/src/internationalization/pht.php
@@ -1,50 +1,52 @@
<?php
/**
* Translate a string. It uses a translator set by
* `PhutilTranslator::setInstance()` or translations specified by
* `PhutilTranslator::getInstance()->setTranslations()` and language rules set
* by `PhutilTranslator::getInstance()->setLocale()`.
*
- * @param string Translation identifier with `sprintf()` placeholders.
- * @param mixed Value to select the variant from (e.g. singular or plural).
+ * @param string $text Translation identifier with `sprintf()` placeholders.
+ * @param mixed $variant (optional) Value to select the variant from (e.g.
+ * singular or plural). Defaults to null.
* @param ... Next values referenced from $text.
* @return string Translated string with substituted values.
*/
function pht($text, $variant = null /* , ... */) {
$args = func_get_args();
$translator = PhutilTranslator::getInstance();
return call_user_func_array(array($translator, 'translate'), $args);
}
/**
* Count all elements in an array, or something in an object.
*
- * @param array|Countable A countable object.
+ * @param array|Countable $countable A countable object.
* @return PhutilNumber Returns the number of elements in the input
* parameter.
*/
function phutil_count($countable) {
if (!(is_array($countable) || $countable instanceof Countable)) {
throw new InvalidArgumentException(pht('Argument should be countable.'));
}
return new PhutilNumber(count($countable));
}
/**
* Provide a gendered argument to the translation engine.
*
* This function does nothing and only serves as a marker for the static
* extractor so it knows particular arguments may vary on gender.
*
- * @param PhutilPerson Something implementing @{interface:PhutilPerson}.
+ * @param PhutilPerson $person Something implementing
+ * @{interface:PhutilPerson}.
* @return PhutilPerson The argument, unmodified.
*/
function phutil_person(PhutilPerson $person) {
return $person;
}
function pht_list(array $items) {
return implode(', ', $items);
}
diff --git a/src/land/engine/ArcanistLandEngine.php b/src/land/engine/ArcanistLandEngine.php
index 692946eb..9c2573ba 100644
--- a/src/land/engine/ArcanistLandEngine.php
+++ b/src/land/engine/ArcanistLandEngine.php
@@ -1,1619 +1,1621 @@
<?php
abstract class ArcanistLandEngine
extends ArcanistWorkflowEngine {
private $sourceRefs;
private $shouldHold;
private $shouldKeep;
private $shouldPreview;
private $isIncremental;
private $ontoRemoteArgument;
private $ontoArguments;
private $intoEmptyArgument;
private $intoLocalArgument;
private $intoRemoteArgument;
private $intoArgument;
private $strategyArgument;
private $strategy;
private $revisionSymbol;
private $revisionSymbolRef;
private $ontoRemote;
private $ontoRefs;
private $intoRemote;
private $intoRef;
private $intoEmpty;
private $intoLocal;
private $localState;
private $hasUnpushedChanges;
private $pickArgument;
final public function setOntoRemote($onto_remote) {
$this->ontoRemote = $onto_remote;
return $this;
}
final public function getOntoRemote() {
return $this->ontoRemote;
}
final public function setOntoRefs($onto_refs) {
$this->ontoRefs = $onto_refs;
return $this;
}
final public function getOntoRefs() {
return $this->ontoRefs;
}
final public function setIntoRemote($into_remote) {
$this->intoRemote = $into_remote;
return $this;
}
final public function getIntoRemote() {
return $this->intoRemote;
}
final public function setIntoRef($into_ref) {
$this->intoRef = $into_ref;
return $this;
}
final public function getIntoRef() {
return $this->intoRef;
}
final public function setIntoEmpty($into_empty) {
$this->intoEmpty = $into_empty;
return $this;
}
final public function getIntoEmpty() {
return $this->intoEmpty;
}
final public function setPickArgument($pick_argument) {
$this->pickArgument = $pick_argument;
return $this;
}
final public function getPickArgument() {
return $this->pickArgument;
}
final public function setIntoLocal($into_local) {
$this->intoLocal = $into_local;
return $this;
}
final public function getIntoLocal() {
return $this->intoLocal;
}
final public function setShouldHold($should_hold) {
$this->shouldHold = $should_hold;
return $this;
}
final public function getShouldHold() {
return $this->shouldHold;
}
final public function setShouldKeep($should_keep) {
$this->shouldKeep = $should_keep;
return $this;
}
final public function getShouldKeep() {
return $this->shouldKeep;
}
final public function setStrategyArgument($strategy_argument) {
$this->strategyArgument = $strategy_argument;
return $this;
}
final public function getStrategyArgument() {
return $this->strategyArgument;
}
final public function setStrategy($strategy) {
$this->strategy = $strategy;
return $this;
}
final public function getStrategy() {
return $this->strategy;
}
final public function setRevisionSymbol($revision_symbol) {
$this->revisionSymbol = $revision_symbol;
return $this;
}
final public function getRevisionSymbol() {
return $this->revisionSymbol;
}
final public function setRevisionSymbolRef(
ArcanistRevisionSymbolRef $revision_ref) {
$this->revisionSymbolRef = $revision_ref;
return $this;
}
final public function getRevisionSymbolRef() {
return $this->revisionSymbolRef;
}
final public function setShouldPreview($should_preview) {
$this->shouldPreview = $should_preview;
return $this;
}
final public function getShouldPreview() {
return $this->shouldPreview;
}
final public function setSourceRefs(array $source_refs) {
$this->sourceRefs = $source_refs;
return $this;
}
final public function getSourceRefs() {
return $this->sourceRefs;
}
final public function setOntoRemoteArgument($remote_argument) {
$this->ontoRemoteArgument = $remote_argument;
return $this;
}
final public function getOntoRemoteArgument() {
return $this->ontoRemoteArgument;
}
final public function setOntoArguments(array $onto_arguments) {
$this->ontoArguments = $onto_arguments;
return $this;
}
final public function getOntoArguments() {
return $this->ontoArguments;
}
final public function setIsIncremental($is_incremental) {
$this->isIncremental = $is_incremental;
return $this;
}
final public function getIsIncremental() {
return $this->isIncremental;
}
final public function setIntoEmptyArgument($into_empty_argument) {
$this->intoEmptyArgument = $into_empty_argument;
return $this;
}
final public function getIntoEmptyArgument() {
return $this->intoEmptyArgument;
}
final public function setIntoLocalArgument($into_local_argument) {
$this->intoLocalArgument = $into_local_argument;
return $this;
}
final public function getIntoLocalArgument() {
return $this->intoLocalArgument;
}
final public function setIntoRemoteArgument($into_remote_argument) {
$this->intoRemoteArgument = $into_remote_argument;
return $this;
}
final public function getIntoRemoteArgument() {
return $this->intoRemoteArgument;
}
final public function setIntoArgument($into_argument) {
$this->intoArgument = $into_argument;
return $this;
}
final public function getIntoArgument() {
return $this->intoArgument;
}
private function setLocalState(ArcanistRepositoryLocalState $local_state) {
$this->localState = $local_state;
return $this;
}
final protected function getLocalState() {
return $this->localState;
}
private function setHasUnpushedChanges($unpushed) {
$this->hasUnpushedChanges = $unpushed;
return $this;
}
final protected function getHasUnpushedChanges() {
return $this->hasUnpushedChanges;
}
final protected function getOntoConfigurationKey() {
return 'arc.land.onto';
}
final protected function getOntoFromConfiguration() {
$config_key = $this->getOntoConfigurationKey();
return $this->getWorkflow()->getConfig($config_key);
}
final protected function getOntoRemoteConfigurationKey() {
return 'arc.land.onto-remote';
}
final protected function getOntoRemoteFromConfiguration() {
$config_key = $this->getOntoRemoteConfigurationKey();
return $this->getWorkflow()->getConfig($config_key);
}
final protected function getStrategyConfigurationKey() {
return 'arc.land.strategy';
}
final protected function getStrategyFromConfiguration() {
$config_key = $this->getStrategyConfigurationKey();
return $this->getWorkflow()->getConfig($config_key);
}
final protected function confirmRevisions(array $sets) {
assert_instances_of($sets, 'ArcanistLandCommitSet');
$revision_refs = mpull($sets, 'getRevisionRef');
$viewer = $this->getViewer();
$viewer_phid = $viewer->getPHID();
$unauthored = array();
foreach ($revision_refs as $revision_ref) {
$author_phid = $revision_ref->getAuthorPHID();
if ($author_phid !== $viewer_phid) {
$unauthored[] = $revision_ref;
}
}
if ($unauthored) {
$this->getWorkflow()->loadHardpoints(
$unauthored,
array(
ArcanistRevisionRef::HARDPOINT_AUTHORREF,
));
echo tsprintf(
"\n%!\n%W\n\n",
pht('NOT REVISION AUTHOR'),
pht(
'You are landing revisions which you ("%s") are not the author of:',
$viewer->getMonogram()));
foreach ($unauthored as $revision_ref) {
$display_ref = $revision_ref->newRefView();
$author_ref = $revision_ref->getAuthorRef();
if ($author_ref) {
$display_ref->appendLine(
pht(
'Author: %s',
$author_ref->getMonogram()));
}
echo tsprintf('%s', $display_ref);
}
echo tsprintf(
"\n%?\n",
pht(
'Use "Commandeer" in the web interface to become the author of '.
'a revision.'));
$query = pht('Land revisions you are not the author of?');
$this->getWorkflow()
->getPrompt('arc.land.unauthored')
->setQuery($query)
->execute();
}
$planned = array();
$published = array();
$not_accepted = array();
foreach ($revision_refs as $revision_ref) {
if ($revision_ref->isStatusChangesPlanned()) {
$planned[] = $revision_ref;
} else if ($revision_ref->isStatusPublished()) {
$published[] = $revision_ref;
} else if (!$revision_ref->isStatusAccepted()) {
$not_accepted[] = $revision_ref;
}
}
// See T10233. Previously, this prompt was bundled with the generic "not
// accepted" prompt, but users found it confusing and interpreted the
// prompt as a bug.
if ($planned) {
$example_ref = head($planned);
echo tsprintf(
"\n%!\n%W\n\n%W\n\n%W\n\n",
pht('%s REVISION(S) HAVE CHANGES PLANNED', phutil_count($planned)),
pht(
'You are landing %s revision(s) which are currently in the state '.
'"%s", indicating that you expect to revise them before moving '.
'forward.',
phutil_count($planned),
$example_ref->getStatusDisplayName()),
pht(
'Normally, you should update these %s revision(s), submit them '.
'for review, and wait for reviewers to accept them before '.
'you continue. To resubmit a revision for review, either: '.
'update the revision with revised changes; or use '.
'"Request Review" from the web interface.',
phutil_count($planned)),
pht(
'These %s revision(s) have changes planned:',
phutil_count($planned)));
foreach ($planned as $revision_ref) {
echo tsprintf('%s', $revision_ref->newRefView());
}
$query = pht(
'Land %s revision(s) with changes planned?',
phutil_count($planned));
$this->getWorkflow()
->getPrompt('arc.land.changes-planned')
->setQuery($query)
->execute();
}
// See PHI1727. Previously, this prompt was bundled with the generic
// "not accepted" prompt, but at least one user found it confusing.
if ($published) {
$example_ref = head($published);
echo tsprintf(
"\n%!\n%W\n\n",
pht('%s REVISION(S) ARE ALREADY PUBLISHED', phutil_count($published)),
pht(
'You are landing %s revision(s) which are already in the state '.
'"%s", indicating that they have previously landed:',
phutil_count($published),
$example_ref->getStatusDisplayName()));
foreach ($published as $revision_ref) {
echo tsprintf('%s', $revision_ref->newRefView());
}
$query = pht(
'Land %s revision(s) that are already published?',
phutil_count($published));
$this->getWorkflow()
->getPrompt('arc.land.published')
->setQuery($query)
->execute();
}
if ($not_accepted) {
$example_ref = head($not_accepted);
echo tsprintf(
"\n%!\n%W\n\n",
pht('%s REVISION(S) ARE NOT ACCEPTED', phutil_count($not_accepted)),
pht(
'You are landing %s revision(s) which are not in state "Accepted", '.
'indicating that they have not been accepted by reviewers. '.
'Normally, you should land changes only once they have been '.
'accepted. These revisions are in the wrong state:',
phutil_count($not_accepted)));
foreach ($not_accepted as $revision_ref) {
$display_ref = $revision_ref->newRefView();
$display_ref->appendLine(
pht(
'Status: %s',
$revision_ref->getStatusDisplayName()));
echo tsprintf('%s', $display_ref);
}
$query = pht(
'Land %s revision(s) in the wrong state?',
phutil_count($not_accepted));
$this->getWorkflow()
->getPrompt('arc.land.not-accepted')
->setQuery($query)
->execute();
}
$this->getWorkflow()->loadHardpoints(
$revision_refs,
array(
ArcanistRevisionRef::HARDPOINT_PARENTREVISIONREFS,
));
$open_parents = array();
foreach ($revision_refs as $revision_phid => $revision_ref) {
$parent_refs = $revision_ref->getParentRevisionRefs();
foreach ($parent_refs as $parent_ref) {
$parent_phid = $parent_ref->getPHID();
// If we're landing a parent revision in this operation, we don't need
// to complain that it hasn't been closed yet.
if (isset($revision_refs[$parent_phid])) {
continue;
}
if ($parent_ref->isClosed()) {
continue;
}
if (!isset($open_parents[$parent_phid])) {
$open_parents[$parent_phid] = array(
'ref' => $parent_ref,
'children' => array(),
);
}
$open_parents[$parent_phid]['children'][] = $revision_ref;
}
}
if ($open_parents) {
echo tsprintf(
"\n%!\n%W\n\n",
pht('%s OPEN PARENT REVISION(S) ', phutil_count($open_parents)),
pht(
'The changes you are landing depend on %s open parent revision(s). '.
'Usually, you should land parent revisions before landing the '.
'changes which depend on them. These parent revisions are open:',
phutil_count($open_parents)));
foreach ($open_parents as $parent_phid => $spec) {
$parent_ref = $spec['ref'];
$display_ref = $parent_ref->newRefView();
$display_ref->appendLine(
pht(
'Status: %s',
$parent_ref->getStatusDisplayName()));
foreach ($spec['children'] as $child_ref) {
$display_ref->appendLine(
pht(
'Parent of: %s %s',
$child_ref->getMonogram(),
$child_ref->getName()));
}
echo tsprintf('%s', $display_ref);
}
$query = pht(
'Land changes that depend on %s open revision(s)?',
phutil_count($open_parents));
$this->getWorkflow()
->getPrompt('arc.land.open-parents')
->setQuery($query)
->execute();
}
$this->confirmBuilds($revision_refs);
// This is a reasonable place to bulk-load the commit messages, which
// we'll need soon.
$this->getWorkflow()->loadHardpoints(
$revision_refs,
array(
ArcanistRevisionRef::HARDPOINT_COMMITMESSAGE,
));
}
private function confirmBuilds(array $revision_refs) {
assert_instances_of($revision_refs, 'ArcanistRevisionRef');
$this->getWorkflow()->loadHardpoints(
$revision_refs,
array(
ArcanistRevisionRef::HARDPOINT_BUILDABLEREF,
));
$buildable_refs = array();
foreach ($revision_refs as $revision_ref) {
$ref = $revision_ref->getBuildableRef();
if ($ref) {
$buildable_refs[] = $ref;
}
}
$this->getWorkflow()->loadHardpoints(
$buildable_refs,
array(
ArcanistBuildableRef::HARDPOINT_BUILDREFS,
));
$build_refs = array();
foreach ($buildable_refs as $buildable_ref) {
foreach ($buildable_ref->getBuildRefs() as $build_ref) {
$build_refs[] = $build_ref;
}
}
$this->getWorkflow()->loadHardpoints(
$build_refs,
array(
ArcanistBuildRef::HARDPOINT_BUILDPLANREF,
));
$problem_builds = array();
$has_failures = false;
$has_ongoing = false;
$build_refs = msortv($build_refs, 'getStatusSortVector');
foreach ($build_refs as $build_ref) {
$plan_ref = $build_ref->getBuildPlanRef();
if (!$plan_ref) {
continue;
}
$plan_behavior = $plan_ref->getBehavior('arc-land', 'always');
$if_building = ($plan_behavior == 'building');
$if_complete = ($plan_behavior == 'complete');
$if_never = ($plan_behavior == 'never');
// If the build plan "Never" warns when landing, skip it.
if ($if_never) {
continue;
}
// If the build plan warns when landing "If Complete" but the build is
// not complete, skip it.
if ($if_complete && !$build_ref->isComplete()) {
continue;
}
// If the build plan warns when landing "If Building" but the build is
// complete, skip it.
if ($if_building && $build_ref->isComplete()) {
continue;
}
// Ignore passing builds.
if ($build_ref->isPassed()) {
continue;
}
if ($build_ref->isComplete()) {
$has_failures = true;
} else {
$has_ongoing = true;
}
$problem_builds[] = $build_ref;
}
if (!$problem_builds) {
return;
}
$build_map = array();
$failure_map = array();
$buildable_map = mpull($buildable_refs, null, 'getPHID');
$revision_map = mpull($revision_refs, null, 'getDiffPHID');
foreach ($problem_builds as $build_ref) {
$buildable_phid = $build_ref->getBuildablePHID();
$buildable_ref = $buildable_map[$buildable_phid];
$object_phid = $buildable_ref->getObjectPHID();
$revision_ref = $revision_map[$object_phid];
$revision_phid = $revision_ref->getPHID();
if (!isset($build_map[$revision_phid])) {
$build_map[$revision_phid] = array(
'revisionRef' => $revision_ref,
'buildRefs' => array(),
);
}
$build_map[$revision_phid]['buildRefs'][] = $build_ref;
}
$log = $this->getLogEngine();
if ($has_failures) {
if ($has_ongoing) {
$message = pht(
'%s revision(s) have build failures or ongoing builds:',
phutil_count($build_map));
$query = pht(
'Land %s revision(s) anyway, despite ongoing and failed builds?',
phutil_count($build_map));
} else {
$message = pht(
'%s revision(s) have build failures:',
phutil_count($build_map));
$query = pht(
'Land %s revision(s) anyway, despite failed builds?',
phutil_count($build_map));
}
echo tsprintf(
"%!\n%s\n",
pht('BUILD FAILURES'),
$message);
$prompt_key = 'arc.land.failed-builds';
} else if ($has_ongoing) {
echo tsprintf(
"%!\n%s\n",
pht('ONGOING BUILDS'),
pht(
'%s revision(s) have ongoing builds:',
phutil_count($build_map)));
$query = pht(
'Land %s revision(s) anyway, despite ongoing builds?',
phutil_count($build_map));
$prompt_key = 'arc.land.ongoing-builds';
}
$workflow = $this->getWorkflow();
echo tsprintf("\n");
foreach ($build_map as $build_item) {
$revision_ref = $build_item['revisionRef'];
$revision_view = $revision_ref->newRefView();
$buildable_ref = $revision_ref->getBuildableRef();
$buildable_view = $buildable_ref->newRefView();
$raw_uri = $buildable_ref->getURI();
$raw_uri = $workflow->getAbsoluteURI($raw_uri);
$buildable_view->setURI($raw_uri);
$revision_view->addChild($buildable_view);
foreach ($build_item['buildRefs'] as $build_ref) {
$build_view = $build_ref->newRefView();
$buildable_view->addChild($build_view);
}
echo tsprintf('%s', $revision_view);
echo tsprintf("\n");
}
$this->getWorkflow()
->getPrompt($prompt_key)
->setQuery($query)
->execute();
}
final protected function confirmImplicitCommits(array $sets, array $symbols) {
assert_instances_of($sets, 'ArcanistLandCommitSet');
assert_instances_of($symbols, 'ArcanistLandSymbol');
$implicit = array();
foreach ($sets as $set) {
if ($set->hasImplicitCommits()) {
$implicit[] = $set;
}
}
if (!$implicit) {
return;
}
echo tsprintf(
"\n%!\n%W\n",
pht('IMPLICIT COMMITS'),
pht(
'Some commits reachable from the specified sources (%s) are not '.
'associated with revisions, and may not have been reviewed. These '.
'commits will be landed as though they belong to the nearest '.
'ancestor revision:',
$this->getDisplaySymbols($symbols)));
foreach ($implicit as $set) {
$this->printCommitSet($set);
}
$query = pht(
'Continue with this mapping between commits and revisions?');
$this->getWorkflow()
->getPrompt('arc.land.implicit')
->setQuery($query)
->execute();
}
final protected function getDisplaySymbols(array $symbols) {
$display = array();
foreach ($symbols as $symbol) {
$display[] = sprintf('"%s"', addcslashes($symbol->getSymbol(), '\\"'));
}
return implode(', ', $display);
}
final protected function printCommitSet(ArcanistLandCommitSet $set) {
$api = $this->getRepositoryAPI();
$revision_ref = $set->getRevisionRef();
echo tsprintf(
"\n%s",
$revision_ref->newRefView());
foreach ($set->getCommits() as $commit) {
$is_implicit = $commit->getIsImplicitCommit();
$display_hash = $api->getDisplayHash($commit->getHash());
$display_summary = $commit->getDisplaySummary();
if ($is_implicit) {
// NOTE: Mark commits with both a color and a character so the marking
// survives copy/paste.
echo tsprintf(
" ! <bg:yellow> %s </bg> %s\n",
$display_hash,
$display_summary);
} else {
echo tsprintf(
" %s %s\n",
$display_hash,
$display_summary);
}
}
}
final protected function loadRevisionRefs(array $commit_map) {
assert_instances_of($commit_map, 'ArcanistLandCommit');
$api = $this->getRepositoryAPI();
$workflow = $this->getWorkflow();
$state_refs = array();
foreach ($commit_map as $commit) {
$hash = $commit->getHash();
$commit_ref = id(new ArcanistCommitRef())
->setCommitHash($hash);
$state_ref = id(new ArcanistWorkingCopyStateRef())
->setCommitRef($commit_ref);
$state_refs[$hash] = $state_ref;
}
$force_symbol_ref = $this->getRevisionSymbolRef();
$force_ref = null;
if ($force_symbol_ref) {
$workflow->loadHardpoints(
$force_symbol_ref,
ArcanistSymbolRef::HARDPOINT_OBJECT);
$force_ref = $force_symbol_ref->getObject();
if (!$force_ref) {
throw new PhutilArgumentUsageException(
pht(
'Symbol "%s" does not identify a valid revision.',
$force_symbol_ref->getSymbol()));
}
}
$workflow->loadHardpoints(
$state_refs,
ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS);
foreach ($commit_map as $commit) {
$hash = $commit->getHash();
$state_ref = $state_refs[$hash];
$revision_refs = $state_ref->getRevisionRefs();
$commit->setRelatedRevisionRefs($revision_refs);
}
// For commits which have exactly one related revision, select it now.
foreach ($commit_map as $commit) {
$revision_refs = $commit->getRelatedRevisionRefs();
if (count($revision_refs) !== 1) {
continue;
}
$revision_ref = head($revision_refs);
$commit->setExplicitRevisionRef($revision_ref);
}
// If we have a "--revision", select that revision for any commits with
// no known related revisions.
// Also select that revision for any commits which have several possible
// revisions including that revision. This is relatively safe and
// reasonable and doesn't require a warning.
if ($force_ref) {
$force_phid = $force_ref->getPHID();
foreach ($commit_map as $commit) {
if ($commit->getExplicitRevisionRef()) {
continue;
}
$revision_refs = $commit->getRelatedRevisionRefs();
if ($revision_refs) {
$revision_refs = mpull($revision_refs, null, 'getPHID');
if (!isset($revision_refs[$force_phid])) {
continue;
}
}
$commit->setExplicitRevisionRef($force_ref);
}
}
// If we have a "--revision", identify any commits which it is not yet
// selected for. These are commits which are not associated with the
// identified revision but are associated with one or more other revisions.
if ($force_ref) {
$force_phid = $force_ref->getPHID();
$confirm_force = array();
foreach ($commit_map as $key => $commit) {
$revision_ref = $commit->getExplicitRevisionRef();
if (!$revision_ref) {
continue;
}
if ($revision_ref->getPHID() === $force_phid) {
continue;
}
$confirm_force[] = $commit;
}
if ($confirm_force) {
// TODO: Make this more clear.
// TODO: Show all the commits.
throw new PhutilArgumentUsageException(
pht(
'TODO: You are forcing a revision, but commits are associated '.
'with some other revision. Are you REALLY sure you want to land '.
'ALL these commits with a different unrelated revision???'));
}
foreach ($confirm_force as $commit) {
$commit->setExplicitRevisionRef($force_ref);
}
}
// Finally, raise an error if we're left with ambiguous revisions. This
// happens when we have no "--revision" and some commits in the range
// that are associated with more than one revision.
$ambiguous = array();
foreach ($commit_map as $commit) {
if ($commit->getExplicitRevisionRef()) {
continue;
}
if (!$commit->getRelatedRevisionRefs()) {
continue;
}
$ambiguous[] = $commit;
}
if ($ambiguous) {
foreach ($ambiguous as $commit) {
$symbols = $commit->getIndirectSymbols();
$raw_symbols = mpull($symbols, 'getSymbol');
$symbol_list = implode(', ', $raw_symbols);
$display_hash = $api->getDisplayHash($hash);
$revision_refs = $commit->getRelatedRevisionRefs();
// TODO: Include "use 'arc look --type commit abc' to figure out why"
// once that works?
// TODO: We could print all the ambiguous commits.
// TODO: Suggest "--pick" as a remedy once it exists?
echo tsprintf(
"\n%!\n%W\n\n",
pht('AMBIGUOUS REVISION'),
pht(
'The revision associated with commit "%s" (an ancestor of: %s) '.
'is ambiguous. These %s revision(s) are associated with the '.
'commit:',
$display_hash,
implode(', ', $raw_symbols),
phutil_count($revision_refs)));
foreach ($revision_refs as $revision_ref) {
echo tsprintf(
'%s',
$revision_ref->newRefView());
}
echo tsprintf("\n");
throw new PhutilArgumentUsageException(
pht(
'Revision for commit "%s" is ambiguous. Use "--revision" to force '.
'selection of a particular revision.',
$display_hash));
}
}
// NOTE: We may exit this method with commits that are still unassociated.
// These will be handled later by the "implicit commits" mechanism.
}
final protected function confirmCommits(
$into_commit,
array $symbols,
array $commit_map) {
$api = $this->getRepositoryAPI();
$commit_count = count($commit_map);
if (!$commit_count) {
$message = pht(
'There are no commits reachable from the specified sources (%s) '.
'which are not already present in the state you are merging '.
'into ("%s"), so nothing can land.',
$this->getDisplaySymbols($symbols),
$api->getDisplayHash($into_commit));
echo tsprintf(
"\n%!\n%W\n\n",
pht('NOTHING TO LAND'),
$message);
throw new PhutilArgumentUsageException(
pht('There are no commits to land.'));
}
// Reverse the commit list so that it's oldest-first, since this is the
// order we'll use to show revisions.
$commit_map = array_reverse($commit_map, true);
$warn_limit = $this->getWorkflow()->getLargeWorkingSetLimit();
$show_limit = 5;
if ($commit_count > $warn_limit) {
if ($into_commit === null) {
$message = pht(
'There are %s commit(s) reachable from the specified sources (%s). '.
'You are landing into the empty state, so all of these commits '.
'will land:',
new PhutilNumber($commit_count),
$this->getDisplaySymbols($symbols));
} else {
$message = pht(
'There are %s commit(s) reachable from the specified sources (%s) '.
'that are not present in the repository state you are merging '.
'into ("%s"). All of these commits will land:',
new PhutilNumber($commit_count),
$this->getDisplaySymbols($symbols),
$api->getDisplayHash($into_commit));
}
echo tsprintf(
"\n%!\n%W\n",
pht('LARGE WORKING SET'),
$message);
$display_commits = array_merge(
array_slice($commit_map, 0, $show_limit),
array(null),
array_slice($commit_map, -$show_limit));
echo tsprintf("\n");
foreach ($display_commits as $commit) {
if ($commit === null) {
echo tsprintf(
" %s\n",
pht(
'< ... %s more commits ... >',
new PhutilNumber($commit_count - ($show_limit * 2))));
} else {
echo tsprintf(
" %s %s\n",
$api->getDisplayHash($commit->getHash()),
$commit->getDisplaySummary());
}
}
$query = pht(
'Land %s commit(s)?',
new PhutilNumber($commit_count));
$this->getWorkflow()
->getPrompt('arc.land.large-working-set')
->setQuery($query)
->execute();
}
// Build the commit objects into a tree.
foreach ($commit_map as $commit_hash => $commit) {
$parent_map = array();
foreach ($commit->getParents() as $parent) {
if (isset($commit_map[$parent])) {
$parent_map[$parent] = $commit_map[$parent];
}
}
$commit->setParentCommits($parent_map);
}
// Identify the commits which are heads (have no children).
$child_map = array();
foreach ($commit_map as $commit_hash => $commit) {
foreach ($commit->getParents() as $parent) {
$child_map[$parent][$commit_hash] = $commit;
}
}
foreach ($commit_map as $commit_hash => $commit) {
if (isset($child_map[$commit_hash])) {
continue;
}
$commit->setIsHeadCommit(true);
}
return $commit_map;
}
public function execute() {
$api = $this->getRepositoryAPI();
$log = $this->getLogEngine();
$this->validateArguments();
$raw_symbols = $this->getSourceRefs();
if (!$raw_symbols) {
$raw_symbols = $this->getDefaultSymbols();
}
$symbols = array();
foreach ($raw_symbols as $raw_symbol) {
$symbols[] = id(new ArcanistLandSymbol())
->setSymbol($raw_symbol);
}
$this->resolveSymbols($symbols);
$onto_remote = $this->selectOntoRemote($symbols);
$this->setOntoRemote($onto_remote);
$onto_refs = $this->selectOntoRefs($symbols);
$this->confirmOntoRefs($onto_refs);
$this->setOntoRefs($onto_refs);
$this->selectIntoRemote();
$this->selectIntoRef();
$into_commit = $this->selectIntoCommit();
$commit_map = $this->selectCommits($into_commit, $symbols);
$this->loadRevisionRefs($commit_map);
// TODO: It's possible we have a list of commits which includes disjoint
// groups of commits associated with the same revision, or groups of
// commits which do not form a range. We should test that here, since we
// can't land commit groups which are not a single contiguous range.
$revision_groups = array();
foreach ($commit_map as $commit_hash => $commit) {
$revision_ref = $commit->getRevisionRef();
if (!$revision_ref) {
echo tsprintf(
"\n%!\n%W\n\n",
pht('UNKNOWN REVISION'),
pht(
'Unable to determine which revision is associated with commit '.
'"%s". Use "arc diff" to create or update a revision with this '.
'commit, or "--revision" to force selection of a particular '.
'revision.',
$api->getDisplayHash($commit_hash)));
throw new PhutilArgumentUsageException(
pht(
'Unable to determine revision for commit "%s".',
$api->getDisplayHash($commit_hash)));
}
$revision_groups[$revision_ref->getPHID()][] = $commit;
}
$commit_heads = array();
foreach ($commit_map as $commit) {
if ($commit->getIsHeadCommit()) {
$commit_heads[] = $commit;
}
}
$revision_order = array();
foreach ($commit_heads as $head) {
foreach ($head->getAncestorRevisionPHIDs() as $phid) {
$revision_order[$phid] = true;
}
}
$revision_groups = array_select_keys(
$revision_groups,
array_keys($revision_order));
$sets = array();
foreach ($revision_groups as $revision_phid => $group) {
$revision_ref = head($group)->getRevisionRef();
$set = id(new ArcanistLandCommitSet())
->setRevisionRef($revision_ref)
->setCommits($group);
$sets[$revision_phid] = $set;
}
$sets = $this->filterCommitSets($sets);
if (!$this->getShouldPreview()) {
$this->confirmImplicitCommits($sets, $symbols);
}
$log->writeStatus(
pht('LANDING'),
pht('These changes will land:'));
foreach ($sets as $set) {
$this->printCommitSet($set);
}
if ($this->getShouldPreview()) {
$log->writeStatus(
pht('PREVIEW'),
pht('Completed preview of land operation.'));
return;
}
$query = pht('Land these changes?');
$this->getWorkflow()
->getPrompt('arc.land.confirm')
->setQuery($query)
->execute();
$this->confirmRevisions($sets);
$workflow = $this->getWorkflow();
$is_incremental = $this->getIsIncremental();
$is_hold = $this->getShouldHold();
$is_keep = $this->getShouldKeep();
$local_state = $api->newLocalState()
->setWorkflow($workflow)
->saveLocalState();
$this->setLocalState($local_state);
$seen_into = array();
try {
$last_key = last_key($sets);
$need_cascade = array();
$need_prune = array();
foreach ($sets as $set_key => $set) {
// Add these first, so we don't add them multiple times if we need
// to retry a push.
$need_prune[] = $set;
$need_cascade[] = $set;
while (true) {
$into_commit = $this->executeMerge($set, $into_commit);
$this->setHasUnpushedChanges(true);
if ($is_hold) {
$should_push = false;
} else if ($is_incremental) {
$should_push = true;
} else {
$is_last = ($set_key === $last_key);
$should_push = $is_last;
}
if ($should_push) {
try {
$this->pushChange($into_commit);
$this->setHasUnpushedChanges(false);
} catch (ArcanistLandPushFailureException $ex) {
// TODO: If the push fails, fetch and retry if the remote ref
// has moved ahead of us.
if ($this->getIntoLocal()) {
$can_retry = false;
} else if ($this->getIntoEmpty()) {
$can_retry = false;
} else if ($this->getIntoRemote() !== $this->getOntoRemote()) {
$can_retry = false;
} else {
$can_retry = false;
}
if ($can_retry) {
// New commit state here
$into_commit = '..';
continue;
}
throw new PhutilArgumentUsageException(
$ex->getMessage());
}
if ($need_cascade) {
// NOTE: We cascade each set we've pushed, but we're going to
// cascade them from most recent to least recent. This way,
// branches which descend from more recent changes only cascade
// once, directly in to the correct state.
$need_cascade = array_reverse($need_cascade);
foreach ($need_cascade as $cascade_set) {
$this->cascadeState($set, $into_commit);
}
$need_cascade = array();
}
if (!$is_keep) {
$this->pruneBranches($need_prune);
$need_prune = array();
}
}
break;
}
}
if ($is_hold) {
$this->didHoldChanges($into_commit);
$local_state->discardLocalState();
} else {
// TODO: Restore this.
// $this->getWorkflow()->askForRepositoryUpdate();
$this->reconcileLocalState($into_commit, $local_state);
$log->writeSuccess(
pht('DONE'),
pht('Landed changes.'));
}
} catch (Exception $ex) {
$local_state->restoreLocalState();
throw $ex;
} catch (Throwable $ex) {
$local_state->restoreLocalState();
throw $ex;
}
}
protected function validateArguments() {
$log = $this->getLogEngine();
$into_local = $this->getIntoLocalArgument();
$into_empty = $this->getIntoEmptyArgument();
$into_remote = $this->getIntoRemoteArgument();
$into_count = 0;
if ($into_remote !== null) {
$into_count++;
}
if ($into_local) {
$into_count++;
}
if ($into_empty) {
$into_count++;
}
if ($into_count > 1) {
throw new PhutilArgumentUsageException(
pht(
'Arguments "--into-local", "--into-remote", and "--into-empty" '.
'are mutually exclusive.'));
}
$into = $this->getIntoArgument();
if ($into && $into_empty) {
throw new PhutilArgumentUsageException(
pht(
'Arguments "--into" and "--into-empty" are mutually exclusive.'));
}
$strategy = $this->selectMergeStrategy();
$this->setStrategy($strategy);
$is_pick = $this->getPickArgument();
if ($is_pick && !$this->isSquashStrategy()) {
throw new PhutilArgumentUsageException(
pht(
'You can not "--pick" changes under the "merge" strategy.'));
}
// Build the symbol ref here (which validates the format of the symbol),
// but don't load the object until later on when we're sure we actually
// need it, since loading it requires a relatively expensive Conduit call.
$revision_symbol = $this->getRevisionSymbol();
if ($revision_symbol) {
$symbol_ref = id(new ArcanistRevisionSymbolRef())
->setSymbol($revision_symbol);
$this->setRevisionSymbolRef($symbol_ref);
}
// NOTE: When a user provides: "--hold" or "--preview"; and "--incremental"
// or various combinations of remote flags, the flags affecting push/remote
// behavior have no effect.
// These combinations are allowed to support adding "--preview" or "--hold"
// to any command to run the same command with fewer side effects.
}
abstract protected function getDefaultSymbols();
abstract protected function resolveSymbols(array $symbols);
abstract protected function selectOntoRemote(array $symbols);
abstract protected function selectOntoRefs(array $symbols);
abstract protected function confirmOntoRefs(array $onto_refs);
abstract protected function selectIntoRemote();
abstract protected function selectIntoRef();
abstract protected function selectIntoCommit();
abstract protected function selectCommits($into_commit, array $symbols);
abstract protected function executeMerge(
ArcanistLandCommitSet $set,
$into_commit);
abstract protected function pushChange($into_commit);
/**
* Update all local refs that depend on refs selected-and-modified during the
* land. E.g. with branches named change1 -> change2 -> change3 and using
* `arc land change2`, in the general case the local change3 should be
* rebased onto the landed version of change2 so that it no longer has
* out-of-date ancestors.
*
* When multiple revisions are landed at once this will be called in a loop
* for each set, in order of max to min, where max is the latest descendant
* and min is the earliest ancestor. This is done so that non-landing commits
* that are descendants of the latest revision will only be rebased once.
*
- * @param ArcanistLandCommitSet The current commit set to cascade.
+ * @param ArcanistLandCommitSet $set The current commit set to cascade.
+ * @param string $into_commit The commit hash that was landed into.
*/
abstract protected function cascadeState(
ArcanistLandCommitSet $set,
$into_commit);
protected function isSquashStrategy() {
return ($this->getStrategy() === 'squash');
}
/**
* Prunes the given sets of commits. This should be called after the sets
* have been merged.
*
- * @param array The list of ArcanistLandCommitSet to prune, in order of
+ * @param array $sets The list of ArcanistLandCommitSet to prune, in order of
* min to max commit set, where min is the earliest ancestor and max
* is the latest descendant.
*/
abstract protected function pruneBranches(array $sets);
/**
* Restore the local repository to an expected state after landing. This
* should only be called after all changes have been merged, pruned, and
* pushed.
*
- * @param string The commit hash that was landed into.
- * @param ArcanistRepositoryLocalState The local state that was captured
- * at the beginning of the land process. This may include stashed changes.
+ * @param string $into_commit The commit hash that was landed into.
+ * @param ArcanistRepositoryLocalState $state The local state that was
+ * captured at the beginning of the land process. This may include stashed
+ * changes.
*/
abstract protected function reconcileLocalState(
$into_commit,
ArcanistRepositoryLocalState $state);
/**
* Display information to the user about how to proceed since the land
* process was not fully completed. The merged branch has not been pushed.
*
- * @param string The commit hash that was landed into.
+ * @param string $into_commit The commit hash that was landed into.
*/
abstract protected function didHoldChanges($into_commit);
private function selectMergeStrategy() {
$log = $this->getLogEngine();
$supported_strategies = array(
'merge',
'squash',
);
$supported_strategies = array_fuse($supported_strategies);
$strategy_list = implode(', ', $supported_strategies);
$strategy = $this->getStrategyArgument();
if ($strategy !== null) {
if (!isset($supported_strategies[$strategy])) {
throw new PhutilArgumentUsageException(
pht(
'Merge strategy "%s" specified with "--strategy" is unknown. '.
'Supported merge strategies are: %s.',
$strategy,
$strategy_list));
}
$log->writeStatus(
pht('STRATEGY'),
pht(
'Merging with "%s" strategy, selected with "--strategy".',
$strategy));
return $strategy;
}
$strategy = $this->getStrategyFromConfiguration();
if ($strategy !== null) {
if (!isset($supported_strategies[$strategy])) {
throw new PhutilArgumentUsageException(
pht(
'Merge strategy "%s" specified in "%s" configuration is '.
'unknown. Supported merge strategies are: %s.',
$strategy,
$this->getStrategyConfigurationKey(),
$strategy_list));
}
$log->writeStatus(
pht('STRATEGY'),
pht(
'Merging with "%s" strategy, configured with "%s".',
$strategy,
$this->getStrategyConfigurationKey()));
return $strategy;
}
$strategy = 'squash';
$log->writeStatus(
pht('STRATEGY'),
pht(
'Merging with "%s" strategy, the default strategy.',
$strategy));
return $strategy;
}
private function filterCommitSets(array $sets) {
assert_instances_of($sets, 'ArcanistLandCommitSet');
$log = $this->getLogEngine();
// If some of the ancestor revisions are already closed, and the user did
// not specifically indicate that we should land them, and we are using
// a "squash" strategy, discard those sets.
if ($this->isSquashStrategy()) {
$discard = array();
foreach ($sets as $key => $set) {
$revision_ref = $set->getRevisionRef();
if (!$revision_ref->isClosed()) {
continue;
}
if ($set->hasDirectSymbols()) {
continue;
}
$discard[] = $set;
unset($sets[$key]);
}
if ($discard) {
echo tsprintf(
"\n%!\n%W\n",
pht('DISCARDING ANCESTORS'),
pht(
'Some ancestor commits are associated with revisions that have '.
'already been closed. These changes will be skipped:'));
foreach ($discard as $set) {
$this->printCommitSet($set);
}
echo tsprintf("\n");
}
}
// TODO: Some of the revisions we've identified may be mapped to an
// outdated set of commits. We should look in local branches for a better
// set of commits, and try to confirm that the state we're about to land
// is the current state in Differential.
$is_pick = $this->getPickArgument();
if ($is_pick) {
foreach ($sets as $key => $set) {
if ($set->hasDirectSymbols()) {
$set->setIsPick(true);
continue;
}
unset($sets[$key]);
}
}
return $sets;
}
final protected function newPassthruCommand($pattern /* , ... */) {
$workflow = $this->getWorkflow();
$argv = func_get_args();
$api = $this->getRepositoryAPI();
$passthru = call_user_func_array(
array($api, 'newPassthru'),
$argv);
$command = $workflow->newCommand($passthru)
->setResolveOnError(true);
return $command;
}
final protected function newPassthru($pattern /* , ... */) {
$argv = func_get_args();
$command = call_user_func_array(
array($this, 'newPassthruCommand'),
$argv);
return $command->execute();
}
final protected function getOntoRemoteRef() {
return id(new ArcanistRemoteRef())
->setRemoteName($this->getOntoRemote());
}
}
diff --git a/src/lexer/PhutilLexer.php b/src/lexer/PhutilLexer.php
index 36cc897c..ce461255 100644
--- a/src/lexer/PhutilLexer.php
+++ b/src/lexer/PhutilLexer.php
@@ -1,362 +1,362 @@
<?php
/**
* Slow, inefficient regexp-based lexer. Define rules like this:
*
* array(
* 'start' => array(...),
* 'state1' => array(...),
* 'state2' => array(...),
* )
*
* Lexers start at the state named 'start'. Each state should have a list of
* rules which can match in that state. A list of rules looks like this:
*
* array(
* array('\s+', 'space'),
* array('\d+', 'digit'),
* array('\w+', 'word'),
* )
*
* The lexer operates by processing each rule in the current state in order.
* When one matches, it produces a token. For example, the lexer above would
* lex this text:
*
* 3 asdf
*
* ...to produce these tokens (assuming the rules are for the 'start' state):
*
* array('digit', '3', null),
* array('space', ' ', null),
* array('word', 'asdf', null),
*
* A rule can also cause a state transition:
*
* array('zebra', 'animal', 'saw_zebra'),
*
* This would match the text "zebra", emit a token of type "animal", and change
* the parser state to "saw_zebra", causing the lexer to start using the rules
* from that state.
*
* To pop the lexer's state, you can use the special state '!pop'.
*
* Finally, you can provide additional options in the fourth parameter.
* Supported options are `case-insensitive` and `context`.
*
* Possible values for `context` are `push` (push the token value onto the
* context stack), `pop` (pop the context stack and use it to provide context
* for the token), and `discard` (pop the context stack and throw away the
* value).
*
* For example, to lex text like this:
*
* Class::CONSTANT
*
* You can use a rule set like this:
*
* 'start' => array(
* array('\w+(?=::)', 'class', 'saw_class', array('context' => 'push')),
* ),
* 'saw_class' => array(
* array('::', 'operator'),
* array('\w+', 'constant, '!pop', array('context' => 'pop')),
* ),
*
* This would parse the above text into this token stream:
*
* array('class', 'Class', null),
* array('operator', '::', null),
* array('constant', 'CONSTANT', 'Class'),
*
* For a concrete implementation, see @{class:PhutilPHPFragmentLexer}.
*
* @task lexerimpl Lexer Implementation
* @task rule Lexer Rules
* @task tokens Lexer Tokens
*/
abstract class PhutilLexer extends Phobject {
private $processedRules;
private $lastState;
/* -( Lexer Rules )-------------------------------------------------------- */
/**
* Return a set of rules for this lexer. See description in
* @{class:PhutilLexer}.
*
* @return dict Lexer rules.
* @task lexerimpl
*/
abstract protected function getRawRules();
/* -( Lexer Rules )-------------------------------------------------------- */
/**
* Process, normalize, and validate the raw lexer rules.
*
* @task rule
*/
protected function getRules() {
$class = get_class($this);
$raw_rules = $this->getRawRules();
if (!is_array($raw_rules)) {
$type = gettype($raw_rules);
throw new UnexpectedValueException(
pht(
'Expected %s to return array, got %s.',
$class.'->getRawRules()',
$type));
}
if (empty($raw_rules['start'])) {
throw new UnexpectedValueException(
pht(
"Expected %s rules to define rules for state '%s'.",
$class,
'start'));
}
$processed_rules = array();
foreach ($raw_rules as $state => $rules) {
if (!is_array($rules)) {
$type = gettype($rules);
throw new UnexpectedValueException(
pht(
"Expected list of rules for state '%s' in %s, got %s.",
$state,
$class,
$type));
}
foreach ($rules as $key => $rule) {
$n = count($rule);
if ($n < 2 || $n > 4) {
throw new UnexpectedValueException(
pht(
"Expected rule '%s' in state '%s' in %s to have 2-4 elements ".
"(regex, token, [next state], [options]), got %d.",
$key,
$state,
$class,
$n));
}
$rule = array_values($rule);
if (count($rule) == 2) {
$rule[] = null;
}
if (count($rule) == 3) {
$rule[] = array();
}
foreach ($rule[3] as $option => $value) {
switch ($option) {
case 'context':
if ($value !== 'push' &&
$value !== 'pop' &&
$value !== 'discard' &&
$value !== null) {
throw new UnexpectedValueException(
pht(
"Rule '%s' in state '%s' in %s has unknown ".
"context rule '%s', expected '%s', '%s' or '%s'.",
$key,
$state,
$class,
$value,
'push',
'pop',
'discard'));
}
break;
default:
throw new UnexpectedValueException(
pht(
"Rule '%s' in state '%s' in %s has unknown option '%s'.",
$key,
$state,
$class,
$option));
}
}
$flags = 'sS';
// NOTE: The "\G" assertion is an offset-aware version of "^".
$rule[0] = '(\\G'.$rule[0].')'.$flags;
if (@preg_match($rule[0], '') === false) {
$error = error_get_last();
throw new UnexpectedValueException(
pht(
"Rule '%s' in state '%s' in %s defines an ".
"invalid regular expression ('%s'): %s",
$key,
$state,
$class,
$rule[0],
idx($error, 'message')));
}
$next_state = $rule[2];
if ($next_state !== null && $next_state !== '!pop') {
if (empty($raw_rules[$next_state])) {
throw new UnexpectedValueException(
pht(
"Rule '%s' in state '%s' in %s transitions to ".
"state '%s', but there are no rules for that state.",
$key,
$state,
$class,
$next_state));
}
}
$processed_rules[$state][] = $rule;
}
}
return $processed_rules;
}
/* -( Lexer Tokens )------------------------------------------------------- */
/**
* Lex an input string into tokens.
*
- * @param string Input string.
- * @param string Initial lexer state.
+ * @param string $input Input string.
+ * @param string $initial_state (optional) Initial lexer state.
* @return list List of lexer tokens.
* @task tokens
*/
public function getTokens($input, $initial_state = 'start') {
if (empty($this->processedRules)) {
$this->processedRules = $this->getRules();
}
$rules = $this->processedRules;
$this->lastState = null;
$position = 0;
$length = strlen($input);
$tokens = array();
$states = array();
$states[] = 'start';
if ($initial_state != 'start') {
$states[] = $initial_state;
}
$context = array();
while ($position < $length) {
$state_rules = idx($rules, end($states), array());
foreach ($state_rules as $rule) {
$matches = null;
if (!preg_match($rule[0], $input, $matches, 0, $position)) {
continue;
}
list($regexp, $token_type, $next_state, $options) = $rule;
$match_length = strlen($matches[0]);
if (!$match_length) {
if ($next_state === null) {
throw new UnexpectedValueException(
pht(
"Rule '%s' matched a zero-length token and causes no ".
"state transition.",
$regexp));
}
} else {
$position += $match_length;
$token = array($token_type, $matches[0]);
$copt = idx($options, 'context');
if ($copt == 'push') {
$context[] = $matches[0];
$token[] = null;
} else if ($copt == 'pop') {
if (empty($context)) {
throw new UnexpectedValueException(
pht("Rule '%s' popped empty context!", $regexp));
}
$token[] = array_pop($context);
} else if ($copt == 'discard') {
if (empty($context)) {
throw new UnexpectedValueException(
pht("Rule '%s' discarded empty context!", $regexp));
}
array_pop($context);
$token[] = null;
} else {
$token[] = null;
}
$tokens[] = $token;
}
if ($next_state !== null) {
if ($next_state == '!pop') {
array_pop($states);
if (empty($states)) {
throw new UnexpectedValueException(
pht("Rule '%s' popped off the last state.", $regexp));
}
} else {
$states[] = $next_state;
}
}
continue 2;
}
throw new UnexpectedValueException(
pht('No lexer rule matched input at char %d.', $position));
}
$this->lastState = $states;
return $tokens;
}
/**
* Merge adjacent tokens of the same type. For example, if a comment is
* tokenized as <"//", "comment">, this method will merge the two tokens into
* a single combined token.
*/
public function mergeTokens(array $tokens) {
$last = null;
$result = array();
foreach ($tokens as $token) {
if ($last === null) {
$last = $token;
continue;
}
if (($token[0] == $last[0]) && ($token[2] == $last[2])) {
$last[1] .= $token[1];
} else {
$result[] = $last;
$last = $token;
}
}
if ($last !== null) {
$result[] = $last;
}
return $result;
}
public function getLexerState() {
return $this->lastState;
}
}
diff --git a/src/lexer/PhutilShellLexer.php b/src/lexer/PhutilShellLexer.php
index d4e8c523..ac17a60a 100644
--- a/src/lexer/PhutilShellLexer.php
+++ b/src/lexer/PhutilShellLexer.php
@@ -1,86 +1,86 @@
<?php
/**
* Lexer for shell-like argument strings. Somewhat similar to Python's shlex.
*/
final class PhutilShellLexer extends PhutilLexer {
/**
* Convert a shell command string into an argument vector. For example, this
* converts a string like:
*
* a 'b c' "d"'"'"e" f\ g "\""
*
* ...into this argument vector:
*
* array(
* "a",
* "b c",
* "d\"e",
* "f g",
* "\"",
* );
*
- * @param string Shell command string.
+ * @param string $string Shell command string.
* @return array Parsed argument vector.
*/
public function splitArguments($string) {
$tokens = $this->getTokens($string);
if (count($this->getLexerState()) > 1) {
throw new UnexpectedValueException(
pht('Unterminated string in argument list!'));
}
foreach ($tokens as $key => $token) {
switch ($token[0]) {
case "'":
case '"':
unset($tokens[$key]);
break;
case 'esc':
$tokens[$key][0] = 'arg';
$tokens[$key][1] = substr($token[1], 1);
break;
default:
break;
}
}
$tokens = $this->mergeTokens(array_values($tokens));
$argv = array();
foreach ($tokens as $token) {
if ($token[0] == 'arg') {
$argv[] = $token[1];
}
}
return $argv;
}
protected function getRawRules() {
return array(
'start' => array(
array('\s+', ' '),
array("'", "'", 'string1'),
array('"', '"', 'string2'),
array('\\\\.', 'esc'),
array('[^\\s\'"\\\\]+', 'arg'),
),
'string1' => array(
// NOTE: In a single-quoted string, backslash is not an escape.
array('[^\']+', 'arg'),
array("'", "'", '!pop'),
),
'string2' => array(
// NOTE: In a double-quoted string, backslash IS an escape, but only
// for some characters: ", $, `, \ and newline.
array('[^"\\\\]+', 'arg'),
array('"', '"', '!pop'),
array('\\\\["$`\\\\\\n]', 'esc'),
array('\\\\.', 'arg'),
),
);
}
}
diff --git a/src/lint/ArcanistLintMessage.php b/src/lint/ArcanistLintMessage.php
index cc9e58fb..749620e5 100644
--- a/src/lint/ArcanistLintMessage.php
+++ b/src/lint/ArcanistLintMessage.php
@@ -1,378 +1,379 @@
<?php
/**
* Message emitted by a linter, like an error or warning.
*/
final class ArcanistLintMessage extends Phobject {
protected $path;
protected $line;
protected $char;
protected $code;
protected $severity;
protected $name;
protected $description;
protected $originalText;
protected $replacementText;
protected $appliedToDisk;
protected $dependentMessages = array();
protected $otherLocations = array();
protected $obsolete;
protected $granularity;
protected $bypassChangedLineFiltering;
public static function newFromDictionary(array $dict) {
$message = new ArcanistLintMessage();
$message->setPath($dict['path']);
if (isset($dict['line'])) {
$message->setLine($dict['line']);
}
if (isset($dict['char'])) {
$message->setChar($dict['char']);
}
$message->setCode($dict['code']);
$message->setSeverity($dict['severity']);
$message->setName($dict['name']);
$message->setDescription($dict['description']);
if (isset($dict['original'])) {
$message->setOriginalText($dict['original']);
}
if (isset($dict['replacement'])) {
$message->setReplacementText($dict['replacement']);
}
$message->setGranularity(idx($dict, 'granularity'));
$message->setOtherLocations(idx($dict, 'locations', array()));
if (isset($dict['bypassChangedLineFiltering'])) {
$message->setBypassChangedLineFiltering(
$dict['bypassChangedLineFiltering']);
}
return $message;
}
public function toDictionary() {
return array(
'path' => $this->getPath(),
'line' => $this->getLine(),
'char' => $this->getChar(),
'code' => $this->getCode(),
'severity' => $this->getSeverity(),
'name' => $this->getName(),
'description' => $this->getDescription(),
'original' => $this->getOriginalText(),
'replacement' => $this->getReplacementText(),
'granularity' => $this->getGranularity(),
'locations' => $this->getOtherLocations(),
'bypassChangedLineFiltering' => $this->shouldBypassChangedLineFiltering(),
);
}
public function setPath($path) {
$this->path = $path;
return $this;
}
public function getPath() {
return $this->path;
}
public function setLine($line) {
$this->line = $this->validateInteger($line, 'setLine');
return $this;
}
public function getLine() {
return $this->line;
}
public function setChar($char) {
$this->char = $this->validateInteger($char, 'setChar');
return $this;
}
public function getChar() {
return $this->char;
}
public function setCode($code) {
$code = (string)$code;
$maximum_bytes = 128;
$actual_bytes = strlen($code);
if ($actual_bytes > $maximum_bytes) {
throw new Exception(
pht(
'Parameter ("%s") passed to "%s" when constructing a lint message '.
'must be a scalar with a maximum string length of %s bytes, but is '.
'%s bytes in length.',
$code,
'setCode()',
new PhutilNumber($maximum_bytes),
new PhutilNumber($actual_bytes)));
}
$this->code = $code;
return $this;
}
public function getCode() {
return $this->code;
}
public function setSeverity($severity) {
$this->severity = $severity;
return $this;
}
public function getSeverity() {
return $this->severity;
}
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 lint 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 setDescription($description) {
$this->description = $description;
return $this;
}
public function getDescription() {
return $this->description;
}
public function setOriginalText($original) {
$this->originalText = $original;
return $this;
}
public function getOriginalText() {
return $this->originalText;
}
public function setReplacementText($replacement) {
$this->replacementText = $replacement;
return $this;
}
public function getReplacementText() {
return $this->replacementText;
}
/**
- * @param dict Keys 'path', 'line', 'char', 'original'.
+ * @param dict $locations Keys 'path', 'line', 'char', 'original'.
*/
public function setOtherLocations(array $locations) {
assert_instances_of($locations, 'array');
$this->otherLocations = $locations;
return $this;
}
public function getOtherLocations() {
return $this->otherLocations;
}
public function isError() {
return $this->getSeverity() == ArcanistLintSeverity::SEVERITY_ERROR;
}
public function isWarning() {
return $this->getSeverity() == ArcanistLintSeverity::SEVERITY_WARNING;
}
public function isAutofix() {
return $this->getSeverity() == ArcanistLintSeverity::SEVERITY_AUTOFIX;
}
public function hasFileContext() {
return ($this->getLine() !== null);
}
public function setObsolete($obsolete) {
$this->obsolete = $obsolete;
return $this;
}
public function getObsolete() {
return $this->obsolete;
}
public function isPatchable() {
return ($this->getReplacementText() !== null) &&
($this->getReplacementText() !== $this->getOriginalText());
}
public function didApplyPatch() {
if ($this->appliedToDisk) {
return $this;
}
$this->appliedToDisk = true;
foreach ($this->dependentMessages as $message) {
$message->didApplyPatch();
}
return $this;
}
public function isPatchApplied() {
return $this->appliedToDisk;
}
public function setGranularity($granularity) {
$this->granularity = $granularity;
return $this;
}
public function getGranularity() {
return $this->granularity;
}
public function setDependentMessages(array $messages) {
assert_instances_of($messages, __CLASS__);
$this->dependentMessages = $messages;
return $this;
}
public function setBypassChangedLineFiltering($bypass_changed_lines) {
$this->bypassChangedLineFiltering = $bypass_changed_lines;
return $this;
}
public function shouldBypassChangedLineFiltering() {
return $this->bypassChangedLineFiltering;
}
/**
* Validate an integer-like value, returning a strict integer.
*
* Further on, the pipeline is strict about types. We want to be a little
* less strict in linters themselves, since they often parse command line
* output or XML and will end up with string representations of numbers.
*
- * @param mixed Integer or digit string.
+ * @param mixed $value Integer or digit string.
+ * @param mixed $caller
* @return int Integer.
*/
private function validateInteger($value, $caller) {
if ($value === null) {
// This just means that we don't have any information.
return null;
}
// Strings like "234" are fine, coerce them to integers.
if (is_string($value) && preg_match('/^\d+\z/', $value)) {
$value = (int)$value;
}
if (!is_int($value)) {
throw new Exception(
pht(
'Parameter passed to "%s" must be an integer.',
$caller.'()'));
}
return $value;
}
public function newTrimmedMessage() {
if (!$this->isPatchable()) {
return clone $this;
}
// If the original and replacement text have a similar prefix or suffix,
// we trim it to reduce the size of the diff we show to the user.
$replacement = $this->getReplacementText();
$original = $this->getOriginalText();
$replacement_length = strlen($replacement);
$original_length = strlen($original);
$minimum_length = min($original_length, $replacement_length);
$prefix_length = 0;
for ($ii = 0; $ii < $minimum_length; $ii++) {
if ($original[$ii] !== $replacement[$ii]) {
break;
}
$prefix_length++;
}
// NOTE: The two strings can't be the same because the message won't be
// "patchable" if they are, so we don't need a special check for the case
// where the entire string is a shared prefix.
// However, if the two strings are in the form "ABC" and "ABBC", we may
// find a prefix and a suffix with a combined length greater than the
// total size of the smaller string if we don't limit the search.
$max_suffix = ($minimum_length - $prefix_length);
$suffix_length = 0;
for ($ii = 1; $ii <= $max_suffix; $ii++) {
$original_char = $original[$original_length - $ii];
$replacement_char = $replacement[$replacement_length - $ii];
if ($original_char !== $replacement_char) {
break;
}
$suffix_length++;
}
if ($suffix_length) {
$original = substr($original, 0, -$suffix_length);
$replacement = substr($replacement, 0, -$suffix_length);
}
$line = $this->getLine();
$char = $this->getChar();
if ($prefix_length) {
$prefix = substr($original, 0, $prefix_length);
// NOTE: Prior to PHP7, `substr("a", 1)` returned false instead of
// the empty string. Cast these to force the PHP7-ish behavior we
// expect.
$original = (string)substr($original, $prefix_length);
$replacement = (string)substr($replacement, $prefix_length);
// If we've removed a prefix, we need to push the character and line
// number for the warning forward to account for the characters we threw
// away.
for ($ii = 0; $ii < $prefix_length; $ii++) {
$char++;
if ($prefix[$ii] == "\n") {
$line++;
$char = 1;
}
}
}
return id(clone $this)
->setOriginalText($original)
->setReplacementText($replacement)
->setLine($line)
->setChar($char);
}
}
diff --git a/src/lint/engine/ArcanistLintEngine.php b/src/lint/engine/ArcanistLintEngine.php
index add11d15..1ea5b84c 100644
--- a/src/lint/engine/ArcanistLintEngine.php
+++ b/src/lint/engine/ArcanistLintEngine.php
@@ -1,618 +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
*/
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 Resource identifier.
- * @param wild Optionally, default value to return if resource does not
- * exist.
+ * @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 Resource identifier.
- * @param wild Resource.
+ * @param string $key Resource identifier.
+ * @param wild $value Resource.
* @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/ArcanistBaseXHPASTLinter.php b/src/lint/linter/ArcanistBaseXHPASTLinter.php
index f0f44827..83a175bd 100644
--- a/src/lint/linter/ArcanistBaseXHPASTLinter.php
+++ b/src/lint/linter/ArcanistBaseXHPASTLinter.php
@@ -1,246 +1,246 @@
<?php
/**
* @task sharing Sharing Parse Trees
*/
abstract class ArcanistBaseXHPASTLinter extends ArcanistFutureLinter {
private $futures = array();
private $trees = array();
private $exceptions = array();
private $refcount = array();
final public function getCacheVersion() {
$parts = array();
$parts[] = $this->getVersion();
$version = PhutilXHPASTBinary::getVersion();
if ($version) {
$parts[] = $version;
}
return implode('-', $parts);
}
final public function raiseLintAtToken(
XHPASTToken $token,
$code,
$desc,
$replace = null) {
return $this->raiseLintAtOffset(
$token->getOffset(),
$code,
$desc,
$token->getValue(),
$replace);
}
final public function raiseLintAtNode(
XHPASTNode $node,
$code,
$desc,
$replace = null) {
return $this->raiseLintAtOffset(
$node->getOffset(),
$code,
$desc,
$node->getConcreteString(),
$replace);
}
final protected function buildFutures(array $paths) {
return $this->getXHPASTLinter()->buildSharedFutures($paths);
}
protected function didResolveLinterFutures(array $futures) {
$this->getXHPASTLinter()->releaseSharedFutures(array_keys($futures));
}
/* -( Sharing Parse Trees )------------------------------------------------ */
/**
* Get the linter object which is responsible for building parse trees.
*
* When the engine specifies that several XHPAST linters should execute,
* we designate one of them as the one which will actually build parse trees.
* The other linters share trees, so they don't have to recompute them.
*
* Roughly, the first linter to execute elects itself as the builder.
* Subsequent linters request builds and retrieve results from it.
*
* @return ArcanistBaseXHPASTLinter Responsible linter.
* @task sharing
*/
final protected function getXHPASTLinter() {
$resource_key = 'xhpast.linter';
// If we're the first linter to run, share ourselves. Otherwise, grab the
// previously shared linter.
$engine = $this->getEngine();
$linter = $engine->getLinterResource($resource_key);
if (!$linter) {
$linter = $this;
$engine->setLinterResource($resource_key, $linter);
}
$base_class = __CLASS__;
if (!($linter instanceof $base_class)) {
throw new Exception(
pht(
'Expected resource "%s" to be an instance of "%s"!',
$resource_key,
$base_class));
}
return $linter;
}
/**
* Build futures on this linter, for use and to share with other linters.
*
- * @param list<string> Paths to build futures for.
+ * @param list<string> $paths Paths to build futures for.
* @return list<ExecFuture> Futures.
* @task sharing
*/
final protected function buildSharedFutures(array $paths) {
foreach ($paths as $path) {
if (!isset($this->futures[$path])) {
$this->futures[$path] = PhutilXHPASTBinary::getParserFuture(
$this->getData($path));
$this->refcount[$path] = 1;
} else {
$this->refcount[$path]++;
}
}
return array_select_keys($this->futures, $paths);
}
/**
* Release futures on this linter which are no longer in use elsewhere.
*
- * @param list<string> Paths to release futures for.
+ * @param list<string> $paths Paths to release futures for.
* @return void
* @task sharing
*/
final protected function releaseSharedFutures(array $paths) {
foreach ($paths as $path) {
if (empty($this->refcount[$path])) {
throw new Exception(
pht(
'Imbalanced calls to shared futures: each call to '.
'%s for a path must be paired with a call to %s.',
'buildSharedFutures()',
'releaseSharedFutures()'));
}
$this->refcount[$path]--;
if (!$this->refcount[$path]) {
unset($this->refcount[$path]);
unset($this->futures[$path]);
unset($this->trees[$path]);
unset($this->exceptions[$path]);
}
}
}
/**
* Get a path's tree from the responsible linter.
*
- * @param string Path to retrieve tree for.
+ * @param string $path Path to retrieve tree for.
* @return XHPASTTree|null Tree, or null if unparseable.
* @task sharing
*/
final protected function getXHPASTTreeForPath($path) {
// If we aren't the linter responsible for actually building the parse
// trees, go get the tree from that linter.
if ($this->getXHPASTLinter() !== $this) {
return $this->getXHPASTLinter()->getXHPASTTreeForPath($path);
}
if (!array_key_exists($path, $this->trees)) {
if (!array_key_exists($path, $this->futures)) {
return;
}
$this->trees[$path] = null;
try {
$this->trees[$path] = XHPASTTree::newFromDataAndResolvedExecFuture(
$this->getData($path),
$this->futures[$path]->resolve());
$root = $this->trees[$path]->getRootNode();
$root->buildSelectCache();
$root->buildTokenCache();
} catch (Exception $ex) {
$this->exceptions[$path] = $ex;
}
}
return $this->trees[$path];
}
/**
* Get a path's parse exception from the responsible linter.
*
- * @param string Path to retrieve exception for.
+ * @param string $path Path to retrieve exception for.
* @return Exception|null Parse exception, if available.
* @task sharing
*/
final protected function getXHPASTExceptionForPath($path) {
if ($this->getXHPASTLinter() !== $this) {
return $this->getXHPASTLinter()->getXHPASTExceptionForPath($path);
}
return idx($this->exceptions, $path);
}
/* -( Deprecated )--------------------------------------------------------- */
/**
* Retrieve all calls to some specified function(s).
*
* Returns all descendant nodes which represent a function call to one of the
* specified functions.
*
- * @param XHPASTNode Root node.
- * @param list<string> Function names.
+ * @param XHPASTNode $root Root node.
+ * @param list<string> $function_names Function names.
* @return AASTNodeList
*/
protected function getFunctionCalls(XHPASTNode $root, array $function_names) {
$calls = $root->selectDescendantsOfType('n_FUNCTION_CALL');
$nodes = array();
foreach ($calls as $call) {
$node = $call->getChildByIndex(0);
$name = strtolower($node->getConcreteString());
if (in_array($name, $function_names)) {
$nodes[] = $call;
}
}
return AASTNodeList::newFromTreeAndNodes($root->getTree(), $nodes);
}
public function getSuperGlobalNames() {
return array(
'$GLOBALS',
'$_SERVER',
'$_GET',
'$_POST',
'$_FILES',
'$_COOKIE',
'$_SESSION',
'$_REQUEST',
'$_ENV',
);
}
}
diff --git a/src/lint/linter/ArcanistChmodLinter.php b/src/lint/linter/ArcanistChmodLinter.php
index a8825e72..ceff264b 100644
--- a/src/lint/linter/ArcanistChmodLinter.php
+++ b/src/lint/linter/ArcanistChmodLinter.php
@@ -1,134 +1,134 @@
<?php
/**
* Ensures that files are not executable unless they are either binary or
* contain a shebang.
*/
final class ArcanistChmodLinter extends ArcanistLinter {
const LINT_INVALID_EXECUTABLE = 1;
public function getInfoName() {
return 'Chmod';
}
public function getInfoDescription() {
return pht(
'Checks the permissions on files and ensures that they are not made to '.
'be executable unnecessarily. In particular, a file should not be '.
'executable unless it is either binary or contain a shebang.');
}
public function getLinterName() {
return 'CHMOD';
}
public function getLinterConfigurationName() {
return 'chmod';
}
public function getLintNameMap() {
return array(
self::LINT_INVALID_EXECUTABLE => pht('Invalid Executable'),
);
}
protected function getDefaultMessageSeverity($code) {
return ArcanistLintSeverity::SEVERITY_WARNING;
}
protected function shouldLintBinaryFiles() {
return true;
}
public function lintPath($path) {
$engine = $this->getEngine();
if (is_executable($engine->getFilePathOnDisk($path))) {
if ($engine->isBinaryFile($path)) {
$mime = Filesystem::getMimeType($engine->getFilePathOnDisk($path));
switch ($mime) {
// Archives
case 'application/jar':
case 'application/java-archive':
case 'application/x-bzip2':
case 'application/x-gzip':
case 'application/x-rar-compressed':
case 'application/x-tar':
case 'application/zip':
// Audio
case 'audio/midi':
case 'audio/mpeg':
case 'audio/mp4':
case 'audio/x-wav':
// Fonts
case 'application/vnd.ms-fontobject':
case 'application/x-font-ttf':
case 'application/x-woff':
// Images
case 'application/x-shockwave-flash':
case 'image/gif':
case 'image/jpeg':
case 'image/png':
case 'image/tiff':
case 'image/x-icon':
case 'image/x-ms-bmp':
// Miscellaneous
case 'application/msword':
case 'application/pdf':
case 'application/postscript':
case 'application/rtf':
case 'application/vnd.ms-excel':
case 'application/vnd.ms-powerpoint':
// Video
case 'video/mpeg':
case 'video/quicktime':
case 'video/x-flv':
case 'video/x-msvideo':
case 'video/x-ms-wmv':
$this->raiseLintAtPath(
self::LINT_INVALID_EXECUTABLE,
pht("'%s' files should not be executable.", $mime));
return;
default:
// Path is a binary file, which makes it a valid executable.
return;
}
} else if ($this->getShebang($path)) {
// Path contains a shebang, which makes it a valid executable.
return;
} else {
$this->raiseLintAtPath(
self::LINT_INVALID_EXECUTABLE,
pht(
'Executable files should either be binary or contain a shebang.'));
}
}
}
/**
* Returns the path's shebang.
*
- * @param string
+ * @param string $path
* @return string|null
*/
private function getShebang($path) {
$line = head(phutil_split_lines($this->getEngine()->loadData($path), true));
$matches = array();
if (preg_match('/^#!(.*)$/', $line, $matches)) {
return $matches[1];
} else {
return null;
}
}
}
diff --git a/src/lint/linter/ArcanistExternalLinter.php b/src/lint/linter/ArcanistExternalLinter.php
index 8c35e440..24e1c09f 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> New flags.
+ * @param list<string> $flags New flags.
* @return this
* @task bin
*/
final public function setFlags(array $flags) {
$this->flags = $flags;
return $this;
}
/**
* Set the binary's version requirement.
*
- * @param string Version requirement.
+ * @param string $version Version requirement.
* @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 New binary.
+ * @param string $bin New binary.
* @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 New interpreter.
+ * @param string $interpreter New interpreter.
* @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 to the file being linted.
- * @param int Exit code of the linter.
- * @param string Stdout of the linter.
- * @param string Stderr of the linter.
+ * @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 string to check.
+ * @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 to the file being linted
+ * @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 specified in configuration.
+ * @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/ArcanistFutureLinter.php b/src/lint/linter/ArcanistFutureLinter.php
index 7fad5a55..d5daf196 100644
--- a/src/lint/linter/ArcanistFutureLinter.php
+++ b/src/lint/linter/ArcanistFutureLinter.php
@@ -1,57 +1,57 @@
<?php
abstract class ArcanistFutureLinter extends ArcanistLinter {
private $futures;
abstract protected function buildFutures(array $paths);
abstract protected function resolveFuture($path, Future $future);
final protected function getFuturesLimit() {
return 8;
}
public function willLintPaths(array $paths) {
$limit = $this->getFuturesLimit();
$this->futures = id(new FutureIterator(array()))->limit($limit);
foreach ($this->buildFutures($paths) as $path => $future) {
$future->setFutureKey($path);
$this->futures->addFuture($future);
}
}
final public function lintPath($path) {
return;
}
public function didLintPaths(array $paths) {
if (!$this->futures) {
return;
}
$map = array();
foreach ($this->futures as $path => $future) {
$this->setActivePath($path);
$this->resolveFuture($path, $future);
$map[$path] = $future;
}
$this->futures = array();
$this->didResolveLinterFutures($map);
}
/**
* Hook for cleaning up resources.
*
* This is invoked after a block of futures resolve, and allows linters to
* discard or clean up any shared resources they no longer need.
*
- * @param map<string, Future> Map of paths to resolved futures.
+ * @param map<string, Future> $futures Map of paths to resolved futures.
* @return void
*/
protected function didResolveLinterFutures(array $futures) {
return;
}
}
diff --git a/src/lint/linter/ArcanistLinter.php b/src/lint/linter/ArcanistLinter.php
index 85a94453..4fc50861 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 Unique linter ID.
+ * @param string $id Unique linter ID.
* @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> A list of paths to be linted
+ * @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 to lint.
+ * @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> A list of paths which were linted.
+ * @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>
+ * @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 specified in configuration.
+ * @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/ArcanistScriptAndRegexLinter.php b/src/lint/linter/ArcanistScriptAndRegexLinter.php
index 62000c31..7071fc4a 100644
--- a/src/lint/linter/ArcanistScriptAndRegexLinter.php
+++ b/src/lint/linter/ArcanistScriptAndRegexLinter.php
@@ -1,392 +1,393 @@
<?php
/**
* Simple glue linter which runs some script on each path, and then uses a
* regex to parse lint messages from the script's output. (This linter uses a
* script and a regex to interpret the results of some real linter, it does
* not itself lint both scripts and regexes).
*
* Configure this linter by setting these keys in your .arclint section:
*
* - `script-and-regex.script` Script command to run. This can be
* the path to a linter script, but may also include flags or use shell
* features (see below for examples).
* - `script-and-regex.regex` The regex to process output with. This
* regex uses named capturing groups (detailed below) to interpret output.
*
* The script will be invoked from the project root, so you can specify a
* relative path like `scripts/lint.sh` or an absolute path like
* `/opt/lint/lint.sh`.
*
* This linter is necessarily more limited in its capabilities than a normal
* linter which can perform custom processing, but may be somewhat simpler to
* configure.
*
* == Script... ==
*
* The script will be invoked once for each file that is to be linted, with
* the file passed as the first argument. The file may begin with a "-"; ensure
* your script will not interpret such files as flags (perhaps by ending your
* script configuration with "--", if its argument parser supports that).
*
* Note that when run via `arc diff`, the list of files to be linted includes
* deleted files and files that were moved away by the change. The linter should
* not assume the path it is given exists, and it is not an error for the
* linter to be invoked with paths which are no longer there. (Every affected
* path is subject to lint because some linters may raise errors in other files
* when a file is removed, or raise an error about its removal.)
*
* The script should emit lint messages to stdout, which will be parsed with
* the provided regex.
*
* For example, you might use a configuration like this:
*
* /opt/lint/lint.sh --flag value --other-flag --
*
* stderr is ignored. If you have a script which writes messages to stderr,
* you can redirect stderr to stdout by using a configuration like this:
*
* sh -c '/opt/lint/lint.sh "$0" 2>&1'
*
* The return code of the script must be 0, or an exception will be raised
* reporting that the linter failed. If you have a script which exits nonzero
* under normal circumstances, you can force it to always exit 0 by using a
* configuration like this:
*
* sh -c '/opt/lint/lint.sh "$0" || true'
*
* Multiple instances of the script will be run in parallel if there are
* multiple files to be linted, so they should not use any unique resources.
* For instance, this configuration would not work properly, because several
* processes may attempt to write to the file at the same time:
*
* COUNTEREXAMPLE
* sh -c '/opt/lint/lint.sh --output /tmp/lint.out "$0" && cat /tmp/lint.out'
*
* There are necessary limits to how gracefully this linter can deal with
* edge cases, because it is just a script and a regex. If you need to do
* things that this linter can't handle, you can write a phutil linter and move
* the logic to handle those cases into PHP. PHP is a better general-purpose
* programming language than regular expressions are, if only by a small margin.
*
* == ...and Regex ==
*
* The regex must be a valid PHP PCRE regex, including delimiters and flags.
*
* The regex will be matched against the entire output of the script, so it
* should generally be in this form if messages are one-per-line:
*
* /^...$/m
*
* The regex should capture these named patterns with `(?P<name>...)`:
*
* - `message` (required) Text describing the lint message. For example,
* "This is a syntax error.".
* - `name` (optional) Text summarizing the lint message. For example,
* "Syntax Error".
* - `severity` (optional) The word "error", "warning", "autofix", "advice",
* or "disabled", in any combination of upper and lower case. Instead, you
* may match groups called `error`, `warning`, `advice`, `autofix`, or
* `disabled`. These allow you to match output formats like "E123" and
* "W123" to indicate errors and warnings, even though the word "error" is
* not present in the output. If no severity capturing group is present,
* messages are raised with "error" severity. If multiple severity capturing
* groups are present, messages are raised with the highest captured
* severity. Capturing groups like `error` supersede the `severity`
* capturing group.
* - `error` (optional) Match some nonempty substring to indicate that this
* message has "error" severity.
* - `warning` (optional) Match some nonempty substring to indicate that this
* message has "warning" severity.
* - `advice` (optional) Match some nonempty substring to indicate that this
* message has "advice" severity.
* - `autofix` (optional) Match some nonempty substring to indicate that this
* message has "autofix" severity.
* - `disabled` (optional) Match some nonempty substring to indicate that this
* message has "disabled" severity.
* - `file` (optional) The name of the file to raise the lint message in. If
* not specified, defaults to the linted file. It is generally not necessary
* to capture this unless the linter can raise messages in files other than
* the one it is linting.
* - `line` (optional) The line number of the message. If no text is
* captured, the message is assumed to affect the entire file.
* - `char` (optional) The character offset of the message.
* - `offset` (optional) The byte offset of the message. If captured, this
* supersedes `line` and `char`.
* - `original` (optional) The text the message affects.
* - `replacement` (optional) The text that the range captured by `original`
* should be automatically replaced by to resolve the message.
* - `code` (optional) A short error type identifier which can be used
* elsewhere to configure handling of specific types of messages. For
* example, "EXAMPLE1", "EXAMPLE2", etc., where each code identifies a
* class of message like "syntax error", "missing whitespace", etc. This
* allows configuration to later change the severity of all whitespace
* messages, for example.
* - `ignore` (optional) Match some nonempty substring to ignore the match.
* You can use this if your linter sometimes emits text like "No lint
* errors".
* - `stop` (optional) Match some nonempty substring to stop processing input.
* Remaining matches for this file will be discarded, but linting will
* continue with other linters and other files.
* - `halt` (optional) Match some nonempty substring to halt all linting of
* this file by any linter. Linting will continue with other files.
* - `throw` (optional) Match some nonempty substring to throw an error, which
* will stop `arc` completely. You can use this to fail abruptly if you
* encounter unexpected output. All processing will abort.
*
* Numbered capturing groups are ignored.
*
* For example, if your lint script's output looks like this:
*
* error:13 Too many goats!
* warning:22 Not enough boats.
*
* ...you could use this regex to parse it:
*
* /^(?P<severity>warning|error):(?P<line>\d+) (?P<message>.*)$/m
*
* The simplest valid regex for line-oriented output is something like this:
*
* /^(?P<message>.*)$/m
*
* @task lint Linting
* @task linterinfo Linter Information
* @task parse Parsing Output
* @task config Validating Configuration
*/
final class ArcanistScriptAndRegexLinter extends ArcanistLinter {
private $script = null;
private $regex = null;
private $output = array();
public function getInfoName() {
return pht('Script and Regex');
}
public function getInfoDescription() {
return pht(
'Run an external script, then parse its output with a regular '.
'expression. This is a generic binding that can be used to '.
'run custom lint scripts.');
}
/* -( Linting )------------------------------------------------------------ */
/**
* Run the script on each file to be linted.
*
* @task lint
*/
public function willLintPaths(array $paths) {
$root = $this->getProjectRoot();
$futures = array();
foreach ($paths as $path) {
$future = new ExecFuture('%C %s', $this->script, $path);
$future->setCWD($root);
$futures[$path] = $future;
}
$futures = id(new FutureIterator($futures))
->limit(4);
foreach ($futures as $path => $future) {
list($stdout) = $future->resolvex();
$this->output[$path] = $stdout;
}
}
/**
* Run the regex on the output of the script.
*
* @task lint
*/
public function lintPath($path) {
$output = idx($this->output, $path, '');
if (!strlen($output)) {
// No output, but it exited 0, so just move on.
return;
}
$matches = null;
if (!preg_match_all($this->regex, $output, $matches, PREG_SET_ORDER)) {
// Output with no matches. This might be a configuration error, but more
// likely it's something like "No lint errors." and the user just hasn't
// written a sufficiently powerful/ridiculous regexp to capture it into an
// 'ignore' group. Don't make them figure this out; advanced users can
// capture 'throw' to handle this case.
return;
}
foreach ($matches as $match) {
if (!empty($match['throw'])) {
$throw = $match['throw'];
throw new ArcanistUsageException(
pht(
"%s: configuration captured a '%s' named capturing group, ".
"'%s'. Script output:\n%s",
__CLASS__,
'throw',
$throw,
$output));
}
if (!empty($match['halt'])) {
$this->stopAllLinters();
break;
}
if (!empty($match['stop'])) {
break;
}
if (!empty($match['ignore'])) {
continue;
}
list($line, $char) = $this->getMatchLineAndChar($match, $path);
$dict = array(
'path' => idx($match, 'file', $path),
'line' => $line,
'char' => $char,
'code' => idx($match, 'code', $this->getLinterName()),
'severity' => $this->getMatchSeverity($match),
'name' => idx($match, 'name', 'Lint'),
'description' => idx($match, 'message', pht('Undefined Lint Message')),
);
$original = idx($match, 'original');
if ($original !== null) {
$dict['original'] = $original;
}
$replacement = idx($match, 'replacement');
if ($replacement !== null) {
$dict['replacement'] = $replacement;
}
$lint = ArcanistLintMessage::newFromDictionary($dict);
$this->addLintMessage($lint);
}
}
/* -( Linter Information )------------------------------------------------- */
/**
* Return the short name of the linter.
*
* @return string Short linter identifier.
*
* @task linterinfo
*/
public function getLinterName() {
return 'S&RX';
}
public function getLinterConfigurationName() {
return 'script-and-regex';
}
public function getLinterConfigurationOptions() {
// These fields are optional only to avoid breaking things.
$options = array(
'script-and-regex.script' => array(
'type' => 'string',
'help' => pht('Script to execute.'),
),
'script-and-regex.regex' => array(
'type' => 'regex',
'help' => pht('The regex to process output with.'),
),
);
return $options + parent::getLinterConfigurationOptions();
}
public function setLinterConfigurationValue($key, $value) {
switch ($key) {
case 'script-and-regex.script':
$this->script = $value;
return;
case 'script-and-regex.regex':
$this->regex = $value;
return;
}
return parent::setLinterConfigurationValue($key, $value);
}
/* -( Parsing Output )----------------------------------------------------- */
/**
* Get the line and character of the message from the regex match.
*
- * @param dict Captured groups from regex.
+ * @param dict $match Captured groups from regex.
+ * @param string $path
* @return pair<int|null,int|null> Line and character of the message.
*
* @task parse
*/
private function getMatchLineAndChar(array $match, $path) {
if (!empty($match['offset'])) {
list($line, $char) = $this->getEngine()->getLineAndCharFromOffset(
idx($match, 'file', $path),
$match['offset']);
return array($line + 1, $char + 1);
}
$line = idx($match, 'line', '');
if (strlen($line)) {
$line = (int)$line;
if (!$line) {
$line = 1;
}
} else {
$line = null;
}
$char = idx($match, 'char');
if ($char) {
$char = (int)$char;
} else {
$char = null;
}
return array($line, $char);
}
/**
* Map the regex matching groups to a message severity. We look for either
* a nonempty severity name group like 'error', or a group called 'severity'
* with a valid name.
*
- * @param dict Captured groups from regex.
+ * @param dict $match Captured groups from regex.
* @return const @{class:ArcanistLintSeverity} constant.
*
* @task parse
*/
private function getMatchSeverity(array $match) {
$map = array(
'error' => ArcanistLintSeverity::SEVERITY_ERROR,
'warning' => ArcanistLintSeverity::SEVERITY_WARNING,
'autofix' => ArcanistLintSeverity::SEVERITY_AUTOFIX,
'advice' => ArcanistLintSeverity::SEVERITY_ADVICE,
'disabled' => ArcanistLintSeverity::SEVERITY_DISABLED,
);
$severity_name = strtolower(idx($match, 'severity', ''));
foreach ($map as $name => $severity) {
if (!empty($match[$name])) {
return $severity;
} else if ($severity_name == $name) {
return $severity;
}
}
return ArcanistLintSeverity::SEVERITY_ERROR;
}
}
diff --git a/src/lint/linter/ArcanistXHPASTLinter.php b/src/lint/linter/ArcanistXHPASTLinter.php
index 636d8569..d5194319 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>
+ * @param list<ArcanistXHPASTLinterRule> $rules
* @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/__tests__/ArcanistLinterTestCase.php b/src/lint/linter/__tests__/ArcanistLinterTestCase.php
index 19d9ce10..75760fbb 100644
--- a/src/lint/linter/__tests__/ArcanistLinterTestCase.php
+++ b/src/lint/linter/__tests__/ArcanistLinterTestCase.php
@@ -1,382 +1,382 @@
<?php
/**
* Facilitates implementation of test cases for @{class:ArcanistLinter}s.
*/
abstract class ArcanistLinterTestCase extends PhutilTestCase {
/**
* Returns an instance of the linter being tested.
*
* @return ArcanistLinter
*/
protected function getLinter() {
$matches = null;
if (!preg_match('/^(\w+Linter)TestCase$/', get_class($this), $matches) ||
!is_subclass_of($matches[1], 'ArcanistLinter')) {
throw new Exception(pht('Unable to infer linter class name.'));
}
return newv($matches[1], array());
}
abstract public function testLinter();
/**
* Executes all tests from the specified subdirectory. If a linter is not
* explicitly specified, it will be inferred from the name of the test class.
*/
protected function executeTestsInDirectory($root) {
$linter = $this->getLinter();
$files = id(new FileFinder($root))
->withType('f')
->withSuffix('lint-test')
->find();
$test_count = 0;
foreach ($files as $file) {
$this->lintFile($root.$file, $linter);
$test_count++;
}
$this->assertTrue(
($test_count > 0),
pht(
'Expected to find some %s tests in directory %s!',
'.lint-test',
$root));
}
private function lintFile($file, ArcanistLinter $linter) {
$linter = clone $linter;
if (!$linter->canRun()) {
$this->assertSkipped(
pht(
'Linter "%s" can not run.',
get_class($linter)));
}
$contents = Filesystem::readFile($file);
$contents = preg_split('/^~{4,}\n/m', $contents);
if (count($contents) < 2) {
throw new Exception(
pht(
"Expected '%s' separating test case and results.",
'~~~~~~~~~~'));
}
list($data, $expect, $xform, $config) = array_merge(
$contents,
array(null, null));
if ($config) {
$config = phutil_json_decode($config);
} else {
$config = array();
}
PhutilTypeSpec::checkMap(
$config,
array(
'config' => 'optional map<string, wild>',
'mode' => 'optional string',
'path' => 'optional string',
'stopped' => 'optional bool',
));
$exception = null;
$after_lint = null;
$messages = null;
$exception_message = false;
$caught_exception = false;
try {
$path_name = idx($config, 'path');
if ($path_name !== null) {
$basename = basename($path_name);
} else {
$basename = basename($file);
}
$tmp = new TempFile($basename);
Filesystem::writeFile($tmp, $data);
$full_path = (string)$tmp;
$mode = idx($config, 'mode');
if ($mode) {
Filesystem::changePermissions($tmp, octdec($mode));
}
$dir = dirname($full_path);
$working_copy = ArcanistWorkingCopyIdentity::newFromRootAndConfigFile(
$dir,
null,
pht('Unit Test'));
$configuration_manager = new ArcanistConfigurationManager();
$configuration_manager->setWorkingCopyIdentity($working_copy);
$engine = new ArcanistUnitTestableLintEngine();
$engine->setWorkingCopy($working_copy);
$engine->setConfigurationManager($configuration_manager);
$engine->setPaths(array($basename));
$linter->setEngine($engine);
$linter->addPath($basename);
$linter->addData($basename, $data);
foreach (idx($config, 'config', array()) as $key => $value) {
$linter->setLinterConfigurationValue($key, $value);
}
$engine->addLinter($linter);
$engine->addFileData($basename, $data);
$results = $engine->run();
$this->assertEqual(
1,
count($results),
pht('Expect one result returned by linter.'));
$assert_stopped = idx($config, 'stopped');
if ($assert_stopped !== null) {
$this->assertEqual(
$assert_stopped,
$linter->didStopAllLinters(),
$assert_stopped
? pht('Expect linter to be stopped.')
: pht('Expect linter to not be stopped.'));
}
$result = reset($results);
$patcher = ArcanistLintPatcher::newFromArcanistLintResult($result);
$after_lint = $patcher->getModifiedFileContent();
} catch (PhutilTestTerminatedException $ex) {
throw $ex;
} catch (Exception $exception) {
$caught_exception = true;
if ($exception instanceof PhutilAggregateException) {
$caught_exception = false;
foreach ($exception->getExceptions() as $ex) {
if ($ex instanceof ArcanistUsageException ||
$ex instanceof ArcanistMissingLinterException) {
$this->assertSkipped($ex->getMessage());
} else {
$caught_exception = true;
}
}
} else if ($exception instanceof ArcanistUsageException ||
$exception instanceof ArcanistMissingLinterException) {
$this->assertSkipped($exception->getMessage());
}
$exception_message = $exception->getMessage()."\n\n".
$exception->getTraceAsString();
}
$this->assertEqual(false, $caught_exception, $exception_message);
$this->compareLint($basename, $expect, $result);
$this->compareTransform($xform, $after_lint);
}
private function compareLint($file, $expect, ArcanistLintResult $results) {
$expected_results = new ArcanistLintResult();
$expect = trim($expect);
if ($expect) {
$expect = explode("\n", $expect);
} else {
$expect = array();
}
foreach ($expect as $result) {
$parts = explode(':', $result);
$message = new ArcanistLintMessage();
$severity = idx($parts, 0);
$line = idx($parts, 1);
if ($line === '') {
$line = null;
}
$char = idx($parts, 2);
if ($char === '') {
$char = null;
}
$code = idx($parts, 3);
if ($code === '') {
$code = null;
}
if ($severity !== null) {
$message->setSeverity($severity);
}
if ($line !== null) {
$message->setLine($line);
}
if ($char !== null) {
$message->setChar($char);
}
if ($code !== null) {
$message->setCode($code);
}
$expected_results->addMessage($message);
}
$missing = array();
$surprising = $results->getMessages();
// TODO: Make this more efficient.
foreach ($expected_results->getMessages() as $expected_message) {
$found = false;
foreach ($results->getMessages() as $ii => $actual_message) {
if (!self::compareLintMessageProperty(
$expected_message->getSeverity(),
$actual_message->getSeverity())) {
continue;
}
if (!self::compareLintMessageProperty(
$expected_message->getLine(),
$actual_message->getLine())) {
continue;
}
if (!self::compareLintMessageProperty(
$expected_message->getChar(),
$actual_message->getChar())) {
continue;
}
if (!self::compareLintMessageProperty(
$expected_message->getCode(),
$actual_message->getCode())) {
continue;
}
$found = true;
unset($surprising[$ii]);
}
if (!$found) {
$missing[] = $expected_message;
}
}
if ($missing || $surprising) {
$this->assertFailure(
sprintf(
"%s\n%s%s",
pht(
'Lint emitted an unexpected set of messages for file "%s".',
$file),
$this->renderMessages(pht('MISSING MESSAGES'), $missing),
$this->renderMessages(pht('SURPLUS MESSAGES'), $surprising)));
}
}
private function compareTransform($expected, $actual) {
$expected = phutil_string_cast($expected);
if (!strlen($expected)) {
return;
}
$this->assertEqual(
$expected,
$actual,
pht('File as patched by lint did not match the expected patched file.'));
}
/**
* Compare properties of @{class:ArcanistLintMessage} instances.
*
- * @param wild
- * @param wild
+ * @param wild $x
+ * @param wild $y
* @return bool
*/
private static function compareLintMessageProperty($x, $y) {
if ($x === null) {
return true;
}
return ($x === $y);
}
private function renderMessages($header, array $messages) {
if (!$messages) {
$display = tsprintf(
"%s\n",
pht('(No messages.)'));
} else {
$lines = array();
foreach ($messages as $message) {
$line = $message->getLine();
if ($line === null) {
$display_line = pht('<null>');
} else {
$display_line = $line;
}
$char = $message->getChar();
if ($char === null) {
$display_char = pht('<null>');
} else {
$display_char = $char;
}
$code = $message->getCode();
$name = $message->getName();
if ($code !== null && $name !== null) {
$display_code = pht('%s: %s', $code, $name);
} else if ($code !== null) {
$display_code = pht('%s', $code);
} else {
$display_code = null;
}
$severity = $message->getSeverity();
if ($display_code === null) {
$display_message = pht(
'Message with severity "%s" at "%s:%s"',
$severity,
$display_line,
$display_char);
} else {
$display_message = pht(
'Message with severity "%s" at "%s:%s" (%s)',
$severity,
$display_line,
$display_char,
$display_code);
}
$lines[] = tsprintf(
" %s\n",
$display_message);
}
$display = implode('', $lines);
}
return tsprintf(
"%s\n%B\n",
$header,
$display);
}
}
diff --git a/src/lint/linter/standards/ArcanistLinterStandard.php b/src/lint/linter/standards/ArcanistLinterStandard.php
index 77b0ec79..bed4a1ec 100644
--- a/src/lint/linter/standards/ArcanistLinterStandard.php
+++ b/src/lint/linter/standards/ArcanistLinterStandard.php
@@ -1,121 +1,121 @@
<?php
/**
* A "linter standard" is a collection of linter rules with associated
* severities and configuration.
*
* Basically, a linter standard allows a set of linter rules and configuration
* to be easily reused across multiple repositories without duplicating the
* contents of the `.arclint` file (and the associated maintenance costs in
* keeping changes to this file synchronized).
*/
abstract class ArcanistLinterStandard extends Phobject {
/**
* Returns a unique identifier for the linter standard.
*
* @return string
*/
abstract public function getKey();
/**
* Returns a human-readable name for the linter standard.
*
* @return string
*/
abstract public function getName();
/**
* Returns a human-readable description for the linter standard.
*
* @return string
*/
abstract public function getDescription();
/**
* Checks whether the linter standard supports a specified linter.
*
- * @param ArcanistLinter The linter which is being configured.
+ * @param ArcanistLinter $linter The linter which is being configured.
* @return bool True if the linter standard supports the specified
* linter, otherwise false.
*/
abstract public function supportsLinter(ArcanistLinter $linter);
/**
* Get linter configuration.
*
* Returns linter configuration which is passed to
* @{method:ArcanistLinter::setLinterConfigurationValue}.
*
* @return map<string, wild>
*/
public function getLinterConfiguration() {
return array();
}
/**
* Get linter severities.
*
* Returns linter severities which are passed to
* @{method:ArcanistLinter::addCustomSeverityMap}.
*
* @return map
*/
public function getLinterSeverityMap() {
return array();
}
/**
* Load a linter standard by key.
*
- * @param string
- * @param ArcanistLinter
+ * @param string $key
+ * @param ArcanistLinter $linter
* @return ArcanistLinterStandard
*/
final public static function getStandard($key, ArcanistLinter $linter) {
$standards = self::loadAllStandardsForLinter($linter);
if (empty($standards[$key])) {
throw new ArcanistUsageException(
pht(
'No such linter standard. Available standards are: %s.',
implode(', ', array_keys($standards))));
}
return $standards[$key];
}
/**
* Load all linter standards.
*
* @return list<ArcanistLinterStandard>
*/
final public static function loadAllStandards() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getKey')
->execute();
}
/**
* Load all linter standards which support a specified linter.
*
- * @param ArcanistLinter
+ * @param ArcanistLinter $linter
* @return list<ArcanistLinterStandard>
*/
final public static function loadAllStandardsForLinter(
ArcanistLinter $linter) {
$all_standards = self::loadAllStandards();
$standards = array();
foreach ($all_standards as $standard) {
if ($standard->supportsLinter($linter)) {
$standards[$standard->getKey()] = $standard;
}
}
return $standards;
}
}
diff --git a/src/lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php b/src/lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php
index a7d9ab18..24ccb577 100644
--- a/src/lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php
+++ b/src/lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php
@@ -1,153 +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
* @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 The symbol type.
- * @param string The symbol name.
- * @param string|null The default result from the main rule engine.
+ * @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.
+ * 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 name.
+ * @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 name.
+ * @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 name.
+ * @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 name.
+ * @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 name.
+ * @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 name.
+ * @param string $symbol Symbol name.
* @return string Stripped symbol.
* @task util
*/
public static function stripPHPVariable($symbol) {
return preg_replace('/^\$/', '', $symbol);
}
}
diff --git a/src/lint/linter/xhpast/ArcanistXHPASTLinterRule.php b/src/lint/linter/xhpast/ArcanistXHPASTLinterRule.php
index d6828e28..014f30cd 100644
--- a/src/lint/linter/xhpast/ArcanistXHPASTLinterRule.php
+++ b/src/lint/linter/xhpast/ArcanistXHPASTLinterRule.php
@@ -1,276 +1,276 @@
<?php
abstract class ArcanistXHPASTLinterRule extends Phobject {
private $linter = null;
private $lintID = null;
protected $version;
protected $windowsVersion;
final public static function loadAllRules() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getLintID')
->execute();
}
final public function getLintID() {
if ($this->lintID === null) {
$class = new ReflectionClass($this);
$const = $class->getConstant('ID');
if ($const === false) {
throw new Exception(
pht(
'`%s` class `%s` must define an ID constant.',
__CLASS__,
get_class($this)));
}
if (!is_int($const)) {
throw new Exception(
pht(
'`%s` class `%s` has an invalid ID constant. '.
'ID must be an integer.',
__CLASS__,
get_class($this)));
}
$this->lintID = $const;
}
return $this->lintID;
}
abstract public function getLintName();
public function getLintSeverity() {
return ArcanistLintSeverity::SEVERITY_ERROR;
}
public function getLinterConfigurationOptions() {
return array(
'xhpast.php-version' => array(
'type' => 'optional string',
'help' => pht('PHP version to target.'),
),
'xhpast.php-version.windows' => array(
'type' => 'optional string',
'help' => pht('PHP version to target on Windows.'),
),
);
}
public function setLinterConfigurationValue($key, $value) {
switch ($key) {
case 'xhpast.php-version':
$this->version = $value;
return;
case 'xhpast.php-version.windows':
$this->windowsVersion = $value;
return;
}
}
abstract public function process(XHPASTNode $root);
final public function setLinter(ArcanistXHPASTLinter $linter) {
$this->linter = $linter;
return $this;
}
/* -( Proxied Methods )---------------------------------------------------- */
final public function getActivePath() {
return $this->linter->getActivePath();
}
final public function getOtherLocation($offset, $path = null) {
return $this->linter->getOtherLocation($offset, $path);
}
final protected function raiseLintAtPath($desc) {
return $this->linter->raiseLintAtPath($this->getLintID(), $desc);
}
final public function raiseLintAtOffset(
$offset,
$description,
$original = null,
$replacement = null) {
$this->linter->raiseLintAtOffset(
$offset,
$this->getLintID(),
$description,
$original,
$replacement);
}
final protected function raiseLintAtToken(
XHPASTToken $token,
$description,
$replace = null) {
return $this->linter->raiseLintAtToken(
$token,
$this->getLintID(),
$description,
$replace);
}
final protected function raiseLintAtNode(
XHPASTNode $node,
$description,
$replace = null) {
return $this->linter->raiseLintAtNode(
$node,
$this->getLintID(),
$description,
$replace);
}
/* -( Utility )------------------------------------------------------------ */
/**
* Statically evaluate a boolean value from an XHP tree.
*
* TODO: Improve this and move it to XHPAST proper?
*
- * @param string The "semantic string" of a single value.
+ * @param string $string The "semantic string" of a single value.
* @return mixed `true` or `false` if the value could be evaluated
* statically; `null` if static evaluation was not possible.
*/
protected function evaluateStaticBoolean($string) {
switch (strtolower($string)) {
case '0':
case 'null':
case 'false':
return false;
case '1':
case 'true':
return true;
default:
return null;
}
}
/**
* Retrieve all anonymous closure(s).
*
* Returns all descendant nodes which represent an anonymous function
* declaration.
*
- * @param XHPASTNode Root node.
+ * @param XHPASTNode $root Root node.
* @return AASTNodeList
*/
protected function getAnonymousClosures(XHPASTNode $root) {
$func_decls = $root->selectDescendantsOfType('n_FUNCTION_DECLARATION');
$nodes = array();
foreach ($func_decls as $func_decl) {
if ($func_decl->getChildByIndex(2)->getTypeName() == 'n_EMPTY') {
$nodes[] = $func_decl;
}
}
return AASTNodeList::newFromTreeAndNodes($root->getTree(), $nodes);
}
/**
* TODO
*
- * @param XHPASTNode
+ * @param XHPASTNode $variable
* @return string
*/
protected function getConcreteVariableString(XHPASTNode $variable) {
$concrete = $variable->getConcreteString();
// Strip off curly braces as in `$obj->{$property}`.
$concrete = trim($concrete, '{}');
return $concrete;
}
/**
* Retrieve all calls to some specified function(s).
*
* Returns all descendant nodes which represent a function call to one of the
* specified functions.
*
- * @param XHPASTNode Root node.
- * @param list<string> Function names.
+ * @param XHPASTNode $root Root node.
+ * @param list<string> $function_names Function names.
* @return AASTNodeList
*/
protected function getFunctionCalls(XHPASTNode $root, array $function_names) {
$calls = $root->selectDescendantsOfType('n_FUNCTION_CALL');
$nodes = array();
foreach ($calls as $call) {
$node = $call->getChildByIndex(0);
$name = strtolower($node->getConcreteString());
if (in_array($name, $function_names)) {
$nodes[] = $call;
}
}
return AASTNodeList::newFromTreeAndNodes($root->getTree(), $nodes);
}
/**
* Get class/method modifiers.
*
- * @param XHPASTNode A node of type `n_CLASS_DECLARATION` or
+ * @param XHPASTNode $node A node of type `n_CLASS_DECLARATION` or
* `n_METHOD_DECLARATION`.
* @return map<string, bool> Class/method modifiers.
*/
final protected function getModifiers(XHPASTNode $node) {
$modifier_list = $node->getChildByIndex(0);
switch ($modifier_list->getTypeName()) {
case 'n_CLASS_ATTRIBUTES':
case 'n_CLASS_MEMBER_MODIFIER_LIST':
case 'n_METHOD_MODIFIER_LIST':
break;
default:
return array();
}
$modifiers = array();
foreach ($modifier_list->selectDescendantsOfType('n_STRING') as $modifier) {
$modifiers[strtolower($modifier->getConcreteString())] = true;
}
return $modifiers;
}
/**
* Get PHP superglobals.
*
* @return list<string>
*/
public function getSuperGlobalNames() {
return array(
'$GLOBALS',
'$_SERVER',
'$_GET',
'$_POST',
'$_FILES',
'$_COOKIE',
'$_SESSION',
'$_REQUEST',
'$_ENV',
);
}
}
diff --git a/src/moduleutils/PhutilLibraryMapBuilder.php b/src/moduleutils/PhutilLibraryMapBuilder.php
index 7997bf57..e0f7afab 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 Path to the library root.
+ * @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 Maximum number of subprocesses to run in parallel.
+ * @param int $limit Maximum number of subprocesses to run in parallel.
* @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 A library-relative path. If omitted, returns the library
- * root path.
+ * @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 of relative paths to symbols.
- * @param dict Source map (like @{method:loadSourceFileMap}).
+ * @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 Relative path to the source file to analyze.
+ * @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 analysis of all source files.
+ * @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 structure to write.
+ * @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/object/Phobject.php b/src/object/Phobject.php
index 4d799306..2effc9de 100644
--- a/src/object/Phobject.php
+++ b/src/object/Phobject.php
@@ -1,109 +1,110 @@
<?php
/**
* Base class for libphutil objects. Enforces stricter object semantics than
* PHP.
*
* When a program attempts to write to an undeclared object property, PHP
* creates the property. However, in libphutil this is always an error (for
* example, a misspelled property name). Instead of permitting the write,
* subclasses will throw when an undeclared property is written.
*
* When a program attempts to iterate an object (for example, with `foreach`),
* PHP iterates its public members. However, in libphutil this is always an
* error (for example, iterating over the wrong variable). Instead of
* permitting the iteration, subclasses will throw when an object is iterated.
*
* (Legitimately iterable subclasses can provide a working implementation of
* Iterator instead.)
*/
abstract class Phobject implements Iterator {
public function __get($name) {
throw new DomainException(
pht(
'Attempt to read from undeclared property %s.',
get_class($this).'::'.$name));
}
public function __set($name, $value) {
throw new DomainException(
pht(
'Attempt to write to undeclared property %s.',
get_class($this).'::'.$name));
}
#[\ReturnTypeWillChange]
public function current() {
$this->throwOnAttemptedIteration();
}
#[\ReturnTypeWillChange]
public function key() {
$this->throwOnAttemptedIteration();
}
#[\ReturnTypeWillChange]
public function next() {
$this->throwOnAttemptedIteration();
}
#[\ReturnTypeWillChange]
public function rewind() {
$this->throwOnAttemptedIteration();
}
#[\ReturnTypeWillChange]
public function valid() {
$this->throwOnAttemptedIteration();
}
private function throwOnAttemptedIteration() {
throw new DomainException(
pht(
'Attempting to iterate an object (of class %s) which is not iterable.',
get_class($this)));
}
/**
* Read the value of a class constant.
*
* This is the same as just typing `self::CONSTANTNAME`, but throws a more
* useful message if the constant is not defined and allows the constant to
* be limited to a maximum length.
*
- * @param string Name of the constant.
- * @param int|null Maximum number of bytes permitted in the value.
+ * @param string $key Name of the constant.
+ * @param int|null $byte_limit (optional) Maximum number of bytes permitted
+ * in the value.
* @return string Value of the constant.
*/
public function getPhobjectClassConstant($key, $byte_limit = null) {
$class = new ReflectionClass($this);
$const = $class->getConstant($key);
if ($const === false) {
throw new Exception(
pht(
'"%s" class "%s" must define a "%s" constant.',
__CLASS__,
get_class($this),
$key));
}
if ($byte_limit !== null) {
if (!is_string($const) || (strlen($const) > $byte_limit)) {
throw new Exception(
pht(
'"%s" class "%s" has an invalid "%s" property. Field constants '.
'must be strings and no more than %s bytes in length.',
__CLASS__,
get_class($this),
$key,
new PhutilNumber($byte_limit)));
}
}
return $const;
}
}
diff --git a/src/parser/ArcanistDiffParser.php b/src/parser/ArcanistDiffParser.php
index f80917ed..1536b74c 100644
--- a/src/parser/ArcanistDiffParser.php
+++ b/src/parser/ArcanistDiffParser.php
@@ -1,1408 +1,1408 @@
<?php
/**
* Parses diffs from a working copy.
*/
final class ArcanistDiffParser extends Phobject {
protected $repositoryAPI;
protected $text;
protected $line;
protected $lineSaved;
protected $isGit;
protected $isMercurial;
protected $isRCS;
protected $detectBinaryFiles = false;
protected $tryEncoding;
protected $rawDiff;
protected $writeDiffOnFailure;
protected $changes = array();
private $forcePath;
public function setRepositoryAPI(ArcanistRepositoryAPI $repository_api) {
$this->repositoryAPI = $repository_api;
return $this;
}
public function setDetectBinaryFiles($detect) {
$this->detectBinaryFiles = $detect;
return $this;
}
public function setTryEncoding($encoding) {
$this->tryEncoding = $encoding;
return $this;
}
public function forcePath($path) {
$this->forcePath = $path;
return $this;
}
public function setChanges(array $changes) {
assert_instances_of($changes, 'ArcanistDiffChange');
$this->changes = mpull($changes, null, 'getCurrentPath');
return $this;
}
public function parseSubversionDiff(ArcanistSubversionAPI $api, $paths) {
$this->setRepositoryAPI($api);
$diffs = array();
foreach ($paths as $path => $status) {
if ($status & ArcanistRepositoryAPI::FLAG_UNTRACKED ||
$status & ArcanistRepositoryAPI::FLAG_CONFLICT ||
$status & ArcanistRepositoryAPI::FLAG_MISSING) {
unset($paths[$path]);
}
}
$root = null;
$from = array();
foreach ($paths as $path => $status) {
$change = $this->buildChange($path);
if ($status & ArcanistRepositoryAPI::FLAG_ADDED) {
$change->setType(ArcanistDiffChangeType::TYPE_ADD);
} else if ($status & ArcanistRepositoryAPI::FLAG_DELETED) {
$change->setType(ArcanistDiffChangeType::TYPE_DELETE);
} else {
$change->setType(ArcanistDiffChangeType::TYPE_CHANGE);
}
$is_dir = is_dir($api->getPath($path));
if ($is_dir) {
$change->setFileType(ArcanistDiffChangeType::FILE_DIRECTORY);
// We have to go hit the diff even for directories because they may
// have property changes or moves, etc.
}
$is_link = is_link($api->getPath($path));
if ($is_link) {
$change->setFileType(ArcanistDiffChangeType::FILE_SYMLINK);
}
$diff = $api->getRawDiffText($path);
if ($diff) {
$this->parseDiff($diff);
}
$info = $api->getSVNInfo($path);
if (idx($info, 'Copied From URL')) {
if (!$root) {
$rinfo = $api->getSVNInfo('.');
$root = $rinfo['URL'].'/';
}
$cpath = $info['Copied From URL'];
$root_len = strlen($root);
if (!strncmp($cpath, $root, $root_len)) {
$cpath = substr($cpath, $root_len);
// The user can "svn cp /path/to/file@12345 x", which pulls a file out
// of version history at a specific revision. If we just use the path,
// we'll collide with possible changes to that path in the working
// copy below. In particular, "svn cp"-ing a path which no longer
// exists somewhere in the working copy and then adding that path
// gets us to the "origin change type" branches below with a
// TYPE_ADD state on the path. To avoid this, append the origin
// revision to the path so we'll necessarily generate a new change.
// TODO: In theory, you could have an '@' in your path and this could
// cause a collision, e.g. two files named 'f' and 'f@12345'. This is
// at least somewhat the user's fault, though.
if ($info['Copied From Rev']) {
if ($info['Copied From Rev'] != $info['Revision']) {
$cpath .= '@'.$info['Copied From Rev'];
}
}
$change->setOldPath($cpath);
$from[$path] = $cpath;
}
}
$type = $change->getType();
if (($type === ArcanistDiffChangeType::TYPE_MOVE_AWAY ||
$type === ArcanistDiffChangeType::TYPE_DELETE) &&
idx($info, 'Node Kind') === 'directory') {
$change->setFileType(ArcanistDiffChangeType::FILE_DIRECTORY);
}
}
foreach ($paths as $path => $status) {
$change = $this->buildChange($path);
if (empty($from[$path])) {
continue;
}
if (empty($this->changes[$from[$path]])) {
if ($change->getType() == ArcanistDiffChangeType::TYPE_COPY_HERE) {
// If the origin path wasn't changed (or isn't included in this diff)
// and we only copied it, don't generate a changeset for it. This
// keeps us out of trouble when we go to 'arc commit' and need to
// figure out which files should be included in the commit list.
continue;
}
}
$origin = $this->buildChange($from[$path]);
$origin->addAwayPath($change->getCurrentPath());
$type = $origin->getType();
switch ($type) {
case ArcanistDiffChangeType::TYPE_MULTICOPY:
case ArcanistDiffChangeType::TYPE_COPY_AWAY:
// "Add" is possible if you do some bizarre tricks with svn:ignore and
// "svn copy"'ing URLs straight from the repository; you can end up with
// a file that is a copy of itself. See T271.
case ArcanistDiffChangeType::TYPE_ADD:
break;
case ArcanistDiffChangeType::TYPE_DELETE:
$origin->setType(ArcanistDiffChangeType::TYPE_MOVE_AWAY);
break;
case ArcanistDiffChangeType::TYPE_MOVE_AWAY:
$origin->setType(ArcanistDiffChangeType::TYPE_MULTICOPY);
break;
case ArcanistDiffChangeType::TYPE_CHANGE:
$origin->setType(ArcanistDiffChangeType::TYPE_COPY_AWAY);
break;
default:
throw new Exception(pht('Bad origin state %s.', $type));
}
$type = $origin->getType();
switch ($type) {
case ArcanistDiffChangeType::TYPE_MULTICOPY:
case ArcanistDiffChangeType::TYPE_MOVE_AWAY:
$change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE);
break;
case ArcanistDiffChangeType::TYPE_ADD:
case ArcanistDiffChangeType::TYPE_COPY_AWAY:
$change->setType(ArcanistDiffChangeType::TYPE_COPY_HERE);
break;
default:
throw new Exception(pht('Bad origin state %s.', $type));
}
}
return $this->changes;
}
public function parseDiff($diff) {
// Remove leading UTF-8 Byte Order Mark (BOM)
if (substr($diff, 0, 3) == pack('CCC', 0xEF, 0xBB, 0xBF)) {
$diff = substr($diff, 3);
}
if (!strlen(trim($diff))) {
throw new Exception(pht("Can't parse an empty diff!"));
}
// Detect `git-format-patch`, by looking for a "---" line somewhere in
// the file and then a footer with Git version number, which looks like
// this:
//
// --
// 1.8.4.2
//
// Note that `git-format-patch` adds a space after the "--", but we don't
// require it when detecting patches, as trailing whitespace can easily be
// lost in transit.
$detect_patch = '/^---$.*^-- ?[\s\d.]+\z/ms';
$message = null;
if (preg_match($detect_patch, $diff)) {
list($message, $diff) = $this->stripGitFormatPatch($diff);
}
$this->didStartParse($diff);
// Strip off header comments. While `patch` allows comments anywhere in the
// file, `git apply` is more strict. We get these comments in `hg export`
// diffs, and Eclipse can also produce them.
$line = $this->getLineTrimmed();
while (preg_match('/^#/', $line)) {
$line = $this->nextLine();
}
if ($message !== null && strlen($message)) {
// If we found a message during pre-parse steps, add it to the resulting
// changes here.
$change = $this->buildChange(null)
->setType(ArcanistDiffChangeType::TYPE_MESSAGE)
->setMetadata('message', $message);
}
do {
$patterns = array(
// This is a normal SVN text change, probably from "svn diff".
'(?P<type>Index): (?P<cur>.+)',
// This is an SVN text change, probably from "svnlook diff".
'(?P<type>Modified|Added|Deleted|Copied): (?P<cur>.+)',
// This is an SVN property change, probably from "svn diff".
'(?P<type>Property changes on): (?P<cur>.+)',
// This is a git commit message, probably from "git show".
'(?P<type>commit) (?P<hash>[a-f0-9]+)(?: \(.*\))?',
// This is a git diff, probably from "git show" or "git diff".
// Note that the filenames may appear quoted.
'(?P<type>diff --git) (?P<oldnew>.*)',
// RCS Diff
'(?P<type>rcsdiff -u) (?P<oldnew>.*)',
// This is a unified diff, probably from "diff -u" or synthetic diffing.
'(?P<type>---) (?P<old>.+)\s+\d{4}-\d{2}-\d{2}.*',
'(?P<binary>Binary files|Files) '.
'(?P<old>.+)\s+\d{4}-\d{2}-\d{2} and '.
'(?P<new>.+)\s+\d{4}-\d{2}-\d{2} differ.*',
// This is a normal Mercurial text change, probably from "hg diff". It
// may have two "-r" blocks if it came from "hg diff -r x:y".
'(?P<type>diff -r) (?P<hgrev>[a-f0-9]+) (?:-r [a-f0-9]+ )?(?P<cur>.+)',
);
$line = $this->getLineTrimmed();
$match = null;
$ok = $this->tryMatchHeader($patterns, $line, $match);
$failed_parse = false;
if (!$ok && $this->isFirstNonEmptyLine()) {
// 'hg export' command creates so called "extended diff" that
// contains some meta information and comment at the beginning
// (isFirstNonEmptyLine() to check for beginning). Actual mercurial
// code detects where comment ends and unified diff starts by
// searching for "diff -r" or "diff --git" in the text.
$this->saveLine();
$line = $this->nextLineThatLooksLikeDiffStart();
if (!phutil_nonempty_string($line)) {
$failed_parse = true;
} else if (!$this->tryMatchHeader($patterns, $line, $match)) {
// Restore line before guessing to display correct error.
$this->restoreLine();
$failed_parse = true;
}
} else if (!$ok) {
$failed_parse = true;
}
if ($failed_parse) {
$this->didFailParse(
pht(
"Expected a hunk header, like '%s' (svn), '%s' (svn properties), ".
"'%s' (git show), '%s' (git diff), '%s' (unified diff), or ".
"'%s' (hg diff or patch).",
'Index: /path/to/file.ext',
'Property changes on: /path/to/file.ext',
'commit 59bcc3ad6775562f845953cf01624225',
'diff --git',
'--- filename',
'diff -r'));
}
if (isset($match['type'])) {
if ($match['type'] == 'diff --git') {
$filename = self::extractGitCommonFilename($match['oldnew']);
if ($filename !== null) {
$match['old'] = $filename;
$match['cur'] = $filename;
}
}
}
$change = $this->buildChange(idx($match, 'cur'));
if (isset($match['old'])) {
$change->setOldPath($match['old']);
}
if (isset($match['hash'])) {
$change->setCommitHash($match['hash']);
}
if (isset($match['binary'])) {
$change->setFileType(ArcanistDiffChangeType::FILE_BINARY);
$line = $this->nextNonemptyLine();
continue;
}
$line = $this->nextLine();
switch ($match['type']) {
case 'Index':
case 'Modified':
case 'Added':
case 'Deleted':
case 'Copied':
$this->parseIndexHunk($change);
break;
case 'Property changes on':
$this->parsePropertyHunk($change);
break;
case 'diff --git':
$this->setIsGit(true);
$this->parseIndexHunk($change);
break;
case 'commit':
$this->setIsGit(true);
$this->parseCommitMessage($change);
break;
case '---':
$ok = preg_match(
'@^(?:\+\+\+) (.*)\s+\d{4}-\d{2}-\d{2}.*$@',
$line,
$match);
if (!$ok) {
$this->didFailParse(pht(
"Expected '%s' in unified diff.",
'+++ filename'));
}
$change->setCurrentPath($match[1]);
$line = $this->nextLine();
$this->parseChangeset($change);
break;
case 'diff -r':
$this->setIsMercurial(true);
$this->parseIndexHunk($change);
break;
case 'rcsdiff -u':
$this->isRCS = true;
$this->parseIndexHunk($change);
break;
default:
$this->didFailParse(pht('Unknown diff type.'));
break;
}
} while ($this->getLine() !== null);
$this->didFinishParse();
$this->loadSyntheticData();
return $this->changes;
}
protected function tryMatchHeader($patterns, $line, &$match) {
foreach ($patterns as $pattern) {
if (preg_match('@^'.$pattern.'$@', $line, $match)) {
return true;
}
}
return false;
}
protected function parseCommitMessage(ArcanistDiffChange $change) {
$change->setType(ArcanistDiffChangeType::TYPE_MESSAGE);
$message = array();
$line = $this->getLine();
if (preg_match('/^Merge: /', $line)) {
$this->nextLine();
}
$line = $this->getLine();
if (!preg_match('/^Author: /', $line)) {
$this->didFailParse(pht("Expected 'Author:'."));
}
$line = $this->nextLine();
if (!preg_match('/^Date: /', $line)) {
$this->didFailParse(pht("Expected 'Date:'."));
}
while (($line = $this->nextLineTrimmed()) !== null) {
if (strlen($line) && $line[0] != ' ') {
break;
}
// Strip leading spaces from Git commit messages. Note that empty lines
// are represented as just "\n"; don't touch those.
$message[] = preg_replace('/^ /', '', $this->getLine());
}
$message = rtrim(implode('', $message), "\r\n");
$change->setMetadata('message', $message);
}
/**
* Parse an SVN property change hunk. These hunks are ambiguous so just sort
* of try to get it mostly right. It's entirely possible to foil this parser
* (or any other parser) with a carefully constructed property change.
*/
protected function parsePropertyHunk(ArcanistDiffChange $change) {
$line = $this->getLineTrimmed();
if (!preg_match('/^_+$/', $line)) {
$this->didFailParse(pht("Expected '%s'.", '______________________'));
}
$line = $this->nextLine();
while ($line !== null) {
$done = preg_match('/^(Index|Property changes on):/', $line);
if ($done) {
break;
}
// NOTE: Before 1.5, SVN uses "Name". At 1.5 and later, SVN uses
// "Modified", "Added" and "Deleted".
$matches = null;
$ok = preg_match(
'/^(Name|Modified|Added|Deleted): (.*)$/',
$line,
$matches);
if (!$ok) {
$this->didFailParse(
pht("Expected 'Name', 'Added', 'Deleted', or 'Modified'."));
}
$op = $matches[1];
$prop = $matches[2];
list($old, $new) = $this->parseSVNPropertyChange($op, $prop);
if ($old !== null) {
$change->setOldProperty($prop, $old);
}
if ($new !== null) {
$change->setNewProperty($prop, $new);
}
$line = $this->getLine();
}
}
private function parseSVNPropertyChange($op, $prop) {
$old = array();
$new = array();
$target = null;
$line = $this->nextLine();
$prop_index = 2;
while ($line !== null) {
$done = preg_match(
'/^(Modified|Added|Deleted|Index|Property changes on):/',
$line);
if ($done) {
break;
}
$trimline = ltrim($line);
if ($trimline && $trimline[0] == '#') {
// in svn1.7, a line like ## -0,0 +1 ## is put between the Added: line
// and the line with the property change. If we have such a line, we'll
// just ignore it (:
$line = $this->nextLine();
$prop_index = 1;
$trimline = ltrim($line);
}
if ($trimline && $trimline[0] == '+') {
if ($op == 'Deleted') {
$this->didFailParse(pht(
'Unexpected "%s" section in property deletion.',
'+'));
}
$target = 'new';
$line = substr($trimline, $prop_index);
} else if ($trimline && $trimline[0] == '-') {
if ($op == 'Added') {
$this->didFailParse(pht(
'Unexpected "%s" section in property addition.',
'-'));
}
$target = 'old';
$line = substr($trimline, $prop_index);
} else if (!strncmp($trimline, 'Merged', 6)) {
if ($op == 'Added') {
$target = 'new';
} else {
// These can appear on merges. No idea how to interpret this (unclear
// what the old / new values are) and it's of dubious usefulness so
// just throw it away until someone complains.
$target = null;
}
$line = $trimline;
}
if ($target == 'new') {
$new[] = $line;
} else if ($target == 'old') {
$old[] = $line;
}
$line = $this->nextLine();
}
$old = rtrim(implode('', $old));
$new = rtrim(implode('', $new));
if (!strlen($old)) {
$old = null;
}
if (!strlen($new)) {
$new = null;
}
return array($old, $new);
}
protected function setIsGit($git) {
if ($this->isGit !== null && $this->isGit != $git) {
throw new Exception(pht('Git status has changed!'));
}
$this->isGit = $git;
return $this;
}
protected function getIsGit() {
return $this->isGit;
}
public function setIsMercurial($is_mercurial) {
$this->isMercurial = $is_mercurial;
return $this;
}
public function getIsMercurial() {
return $this->isMercurial;
}
protected function parseIndexHunk(ArcanistDiffChange $change) {
$is_git = $this->getIsGit();
$is_mercurial = $this->getIsMercurial();
$is_svn = (!$is_git && !$is_mercurial);
$move_source = null;
$line = $this->getLine();
if ($is_git) {
do {
$patterns = array(
'(?P<new>new) file mode (?P<newmode>\d+)',
'(?P<deleted>deleted) file mode (?P<oldmode>\d+)',
// These occur when someone uses `chmod` on a file.
'old mode (?P<oldmode>\d+)',
'new mode (?P<newmode>\d+)',
// These occur when you `mv` a file and git figures it out.
'similarity index ',
'rename from (?P<old>.*)',
'(?P<move>rename) to (?P<cur>.*)',
'copy from (?P<old>.*)',
'(?P<copy>copy) to (?P<cur>.*)',
);
$ok = false;
$match = null;
if ($line !== null) {
foreach ($patterns as $pattern) {
$ok = preg_match('@^'.$pattern.'@', $line, $match);
if ($ok) {
break;
}
}
}
if (!$ok) {
if ($line === null ||
preg_match('/^(diff --git|commit) /', $line)) {
// In this case, there are ONLY file mode changes, or this is a
// pure move. If it's a move, flag these changesets so we can build
// synthetic changes later, enabling us to show file contents in
// Differential -- git only gives us a block like this:
//
// diff --git a/README b/READYOU
// similarity index 100%
// rename from README
// rename to READYOU
//
// ...i.e., there is no associated diff.
// This allows us to distinguish between property changes only
// and actual moves. For property changes only, we can't currently
// build a synthetic diff correctly, so just skip it.
// TODO: Build synthetic diffs for property changes, too.
if ($change->getType() != ArcanistDiffChangeType::TYPE_CHANGE) {
$change->setNeedsSyntheticGitHunks(true);
if ($move_source) {
$move_source->setNeedsSyntheticGitHunks(true);
}
}
return;
}
break;
}
if (!empty($match['oldmode'])) {
$change->setOldProperty('unix:filemode', $match['oldmode']);
}
if (!empty($match['newmode'])) {
$change->setNewProperty('unix:filemode', $match['newmode']);
}
if (!empty($match['deleted'])) {
$change->setType(ArcanistDiffChangeType::TYPE_DELETE);
}
if (!empty($match['new'])) {
// If you replace a symlink with a normal file, git renders the change
// as a "delete" of the symlink plus an "add" of the new file. We
// prefer to represent this as a change.
if ($change->getType() == ArcanistDiffChangeType::TYPE_DELETE) {
$change->setType(ArcanistDiffChangeType::TYPE_CHANGE);
} else {
$change->setType(ArcanistDiffChangeType::TYPE_ADD);
}
}
if (!empty($match['old'])) {
$match['old'] = self::unescapeFilename($match['old']);
$change->setOldPath($match['old']);
}
if (!empty($match['cur'])) {
$match['cur'] = self::unescapeFilename($match['cur']);
$change->setCurrentPath($match['cur']);
}
if (!empty($match['copy'])) {
$change->setType(ArcanistDiffChangeType::TYPE_COPY_HERE);
$old = $this->buildChange($change->getOldPath());
$type = $old->getType();
if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) {
$old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY);
} else {
$old->setType(ArcanistDiffChangeType::TYPE_COPY_AWAY);
}
$old->addAwayPath($change->getCurrentPath());
}
if (!empty($match['move'])) {
$change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE);
$old = $this->buildChange($change->getOldPath());
$type = $old->getType();
if ($type == ArcanistDiffChangeType::TYPE_MULTICOPY) {
// Great, no change.
} else if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) {
$old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY);
} else if ($type == ArcanistDiffChangeType::TYPE_COPY_AWAY) {
$old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY);
} else {
$old->setType(ArcanistDiffChangeType::TYPE_MOVE_AWAY);
}
// We'll reference this above.
$move_source = $old;
$old->addAwayPath($change->getCurrentPath());
}
$line = $this->nextNonemptyLine();
} while (true);
}
$line = $this->getLine();
if ($is_svn) {
$ok = preg_match('/^=+\s*$/', $line);
if (!$ok) {
$this->didFailParse(pht(
"Expected '%s' divider line.",
'======================='));
} else {
// Adding an empty file in SVN can produce an empty line here.
$line = $this->nextNonemptyLine();
}
} else if ($is_git) {
$ok = preg_match('/^index .*$/', $line);
if (!$ok) {
// TODO: "hg diff -g" diffs ("mercurial git-style diffs") do not include
// this line, so we can't parse them if we fail on it. Maybe introduce
// a flag saying "parse this diff using relaxed git-style diff rules"?
// $this->didFailParse("Expected 'index af23f...a98bc' header line.");
} else {
// NOTE: In the git case, where this patch is the last change in the
// file, we may have a final terminal newline. Skip over it so that
// we'll hit the '$line === null' block below. This is covered by the
// 'git-empty-file.gitdiff' test case.
$line = $this->nextNonemptyLine();
}
}
// If there are files with only whitespace changes and -b or -w are
// supplied as command-line flags to `diff', svn and git both produce
// changes without any body.
if ($line === null ||
preg_match(
'/^(Index:|Property changes on:|diff --git|commit) /',
$line)) {
return;
}
$is_binary_add = preg_match(
'/^Cannot display: file marked as a binary type\.$/',
rtrim($line));
if ($is_binary_add) {
$this->nextLine(); // Cannot display: file marked as a binary type.
$this->nextNonemptyLine(); // svn:mime-type = application/octet-stream
$this->markBinary($change);
return;
}
// We can get this in git, or in SVN when a file exists in the repository
// WITHOUT a binary mime-type and is changed and given a binary mime-type.
$is_binary_diff = preg_match(
'/^(Binary files|Files) .* and .* differ$/',
rtrim($line));
if ($is_binary_diff) {
$this->nextNonemptyLine(); // Binary files x and y differ
$this->markBinary($change);
return;
}
// This occurs under "hg diff --git" when a binary file is removed. See
// test case "hg-binary-delete.hgdiff". (I believe it never occurs under
// git, which reports the "files X and /dev/null differ" string above. Git
// can not apply these patches.)
$is_hg_binary_delete = preg_match(
'/^Binary file .* has changed$/',
rtrim($line));
if ($is_hg_binary_delete) {
$this->nextNonemptyLine();
$this->markBinary($change);
return;
}
// With "git diff --binary" (not a normal mode, but one users may explicitly
// invoke and then, e.g., copy-paste into the web console) or "hg diff
// --git" (normal under hg workflows), we may encounter a literal binary
// patch.
$is_git_binary_patch = preg_match(
'/^GIT binary patch$/',
rtrim($line));
if ($is_git_binary_patch) {
$this->nextLine();
$this->parseGitBinaryPatch();
$line = $this->getLine();
if ($line !== null && preg_match('/^literal/', $line)) {
// We may have old/new binaries (change) or just a new binary (hg add).
// If there are two blocks, parse both.
$this->parseGitBinaryPatch();
}
$this->markBinary($change);
return;
}
if ($is_git) {
// "git diff -b" ignores whitespace, but has an empty hunk target
if (preg_match('@^diff --git .*$@', $line)) {
$this->nextLine();
return null;
}
}
if ($this->isRCS) {
// Skip the RCS headers.
$this->nextLine();
$this->nextLine();
$this->nextLine();
}
$old_file = $this->parseHunkTarget();
$new_file = $this->parseHunkTarget();
if ($this->isRCS) {
$change->setCurrentPath($new_file);
}
$change->setOldPath($old_file);
$this->parseChangeset($change);
}
private function parseGitBinaryPatch() {
// TODO: We could decode the patches, but it's a giant mess so don't bother
// for now. We'll pick up the data from the working copy in the common
// case ("arc diff").
$line = $this->getLine();
if (!preg_match('/^literal /', $line)) {
$this->didFailParse(
pht("Expected '%s' to start git binary patch.", 'literal NNNN'));
}
do {
$line = $this->nextLineTrimmed();
if ($line === '' || $line === null) {
// Some versions of Mercurial apparently omit the terminal newline,
// although it's unclear if Git will ever do this. In either case,
// rely on the base85 check for sanity.
$this->nextNonemptyLine();
return;
} else if (!preg_match('/^[a-zA-Z]/', $line)) {
$this->didFailParse(
pht('Expected base85 line length character (a-zA-Z).'));
}
} while (true);
}
protected function parseHunkTarget() {
$line = $this->getLine();
$matches = null;
$remainder = '(?:\s*\(.*\))?';
if ($this->getIsMercurial()) {
// Something like "Fri Aug 26 01:20:50 2005 -0700", don't bother trying
// to parse it.
$remainder = '\t.*';
} else if ($this->isRCS) {
$remainder = '\s.*';
} else if ($this->getIsGit()) {
// When filenames contain spaces, Git terminates this line with a tab.
// Normally, the tab is not present. If there's a tab, ignore it.
$remainder = '(?:\t.*)?';
}
$ok = preg_match(
'@^[-+]{3} (?:[ab]/)?(?P<path>.*?)'.$remainder.'$@',
$line,
$matches);
if (!$ok) {
$this->didFailParse(
pht(
"Expected hunk target '%s'.",
'+++ path/to/file.ext (revision N)'));
}
$this->nextLine();
return $matches['path'];
}
protected function markBinary(ArcanistDiffChange $change) {
$change->setFileType(ArcanistDiffChangeType::FILE_BINARY);
return $this;
}
protected function parseChangeset(ArcanistDiffChange $change) {
// If a diff includes two sets of changes to the same file, let the
// second one win. In particular, this occurs when adding subdirectories
// in Subversion that contain files: the file text will be present in
// both the directory diff and the file diff. See T5555. Dropping the
// hunks lets whichever one shows up later win instead of showing changes
// twice.
$change->dropHunks();
$all_changes = array();
do {
$hunk = new ArcanistDiffHunk();
$line = $this->getLineTrimmed();
$real = array();
// In the case where only one line is changed, the length is omitted.
// The final group is for git, which appends a guess at the function
// context to the diff.
$matches = null;
$ok = preg_match(
'/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*?)?$/U',
$line,
$matches);
if (!$ok) {
// It's possible we hit the style of an svn1.7 property change.
// This is a 4-line Index block, followed by an empty line, followed
// by a "Property changes on:" section similar to svn1.6.
if ($line == '') {
$line = $this->nextNonemptyLine();
$ok = preg_match('/^Property changes on:/', $line);
if (!$ok) {
$this->didFailParse(pht('Confused by empty line'));
}
$line = $this->nextLine();
return $this->parsePropertyHunk($change);
}
$this->didFailParse(pht(
"Expected hunk header '%s'.",
'@@ -NN,NN +NN,NN @@'));
}
$hunk->setOldOffset($matches[1]);
$hunk->setNewOffset($matches[3]);
// Cover for the cases where length wasn't present (implying one line).
$old_len = idx($matches, 2, '');
if (!strlen($old_len)) {
$old_len = 1;
}
$new_len = idx($matches, 4, '');
if (!strlen($new_len)) {
$new_len = 1;
}
$hunk->setOldLength($old_len);
$hunk->setNewLength($new_len);
$add = 0;
$del = 0;
$hit_next_hunk = false;
while ((($line = $this->nextLine()) !== null)) {
if (strlen(rtrim($line, "\r\n"))) {
$char = $line[0];
} else {
// Normally, we do not encouter empty lines in diffs, because
// unchanged lines have an initial space. However, in Git, with
// the option `diff.suppress-blank-empty` set, unchanged blank lines
// emit as completely empty. If we encounter a completely empty line,
// treat it as a ' ' (i.e., unchanged empty line) line.
$char = ' ';
}
switch ($char) {
case '\\':
if (!preg_match('@\\ No newline at end of file@', $line)) {
$this->didFailParse(
pht("Expected '\ No newline at end of file'."));
}
if ($new_len) {
$real[] = $line;
$hunk->setIsMissingOldNewline(true);
} else {
$real[] = $line;
$hunk->setIsMissingNewNewline(true);
}
if (!$new_len) {
break 2;
}
break;
case '+':
++$add;
--$new_len;
$real[] = $line;
break;
case '-':
if (!$old_len) {
// In this case, we've hit "---" from a new file. So don't
// advance the line cursor.
$hit_next_hunk = true;
break 2;
}
++$del;
--$old_len;
$real[] = $line;
break;
case ' ':
if (!$old_len && !$new_len) {
break 2;
}
--$old_len;
--$new_len;
$real[] = $line;
break;
default:
// We hit something, likely another hunk.
$hit_next_hunk = true;
break 2;
}
}
if ($old_len || $new_len) {
$this->didFailParse(pht('Found the wrong number of hunk lines.'));
}
$corpus = implode('', $real);
$is_binary = false;
if ($this->detectBinaryFiles) {
$is_binary = !phutil_is_utf8($corpus);
$try_encoding = $this->tryEncoding;
if ($is_binary && $try_encoding) {
$is_binary = ArcanistDiffUtils::isHeuristicBinaryFile($corpus);
if (!$is_binary) {
$corpus = phutil_utf8_convert($corpus, 'UTF-8', $try_encoding);
if (!phutil_is_utf8($corpus)) {
throw new Exception(
pht(
"Failed to convert a hunk from '%s' to UTF-8. ".
"Check that the specified encoding is correct.",
$try_encoding));
}
}
}
}
if ($is_binary) {
// SVN happily treats binary files which aren't marked with the right
// mime type as text files. Detect that junk here and mark the file
// binary. We'll catch stuff with unicode too, but that's verboten
// anyway. If there are too many false positives with this we might
// need to make it threshold-triggered instead of triggering on any
// unprintable byte.
$change->setFileType(ArcanistDiffChangeType::FILE_BINARY);
} else {
$hunk->setCorpus($corpus);
$hunk->setAddLines($add);
$hunk->setDelLines($del);
$change->addHunk($hunk);
}
if (!$hit_next_hunk) {
$line = $this->nextNonemptyLine();
}
} while (($line !== null) && preg_match('/^@@ /', $line));
}
protected function buildChange($path = null) {
$change = null;
if ($path !== null) {
if (!empty($this->changes[$path])) {
return $this->changes[$path];
}
}
if ($this->forcePath) {
return $this->changes[$this->forcePath];
}
$change = new ArcanistDiffChange();
if ($path !== null) {
$change->setCurrentPath($path);
$this->changes[$path] = $change;
} else {
$this->changes[] = $change;
}
return $change;
}
protected function didStartParse($text) {
$this->rawDiff = $text;
// Eat leading whitespace. This may happen if the first change in the diff
// is an SVN property change.
$text = ltrim($text);
// Try to strip ANSI color codes from colorized diffs. ANSI color codes
// might be present in two cases:
//
// - You piped a colorized diff into 'arc --raw' or similar (normally
// we're able to disable colorization on diffs we control the generation
// of).
// - You're diffing a file which actually contains ANSI color codes.
//
// The former is vastly more likely, but we try to distinguish between the
// two cases by testing for a color code at the beginning of a line. If
// we find one, we know it's a colorized diff (since the beginning of the
// line should be "+", "-" or " " if the code is in the diff text).
//
// While it's possible a diff might be colorized and fail this test, it's
// unlikely, and it covers hg's color extension which seems to be the most
// stubborn about colorizing text despite stdout not being a TTY.
//
// We might incorrectly strip color codes from a colorized diff of a text
// file with color codes inside it, but this case is stupid and pathological
// and you've dug your own grave.
$ansi_color_pattern = '\x1B\[[\d;]*m';
if (preg_match('/^'.$ansi_color_pattern.'/m', $text)) {
$text = preg_replace('/'.$ansi_color_pattern.'/', '', $text);
}
$this->text = phutil_split_lines($text);
$this->line = 0;
}
protected function getLine() {
if ($this->text === null) {
throw new Exception(pht('Not parsing!'));
}
if (isset($this->text[$this->line])) {
return $this->text[$this->line];
}
return null;
}
protected function getLineTrimmed() {
$line = $this->getLine();
if ($line !== null) {
$line = trim($line, "\r\n");
}
return $line;
}
protected function nextLine() {
$this->line++;
return $this->getLine();
}
protected function nextLineTrimmed() {
$line = $this->nextLine();
if ($line !== null) {
$line = trim($line, "\r\n");
}
return $line;
}
protected function nextNonemptyLine() {
while (($line = $this->nextLine()) !== null) {
if (strlen(trim($line)) !== 0) {
break;
}
}
return $this->getLine();
}
protected function nextLineThatLooksLikeDiffStart() {
while (($line = $this->nextLine()) !== null) {
if (preg_match('/^\s*diff\s+-(?:r|-git)/', $line)) {
break;
}
}
return $this->getLine();
}
protected function saveLine() {
$this->lineSaved = $this->line;
}
protected function restoreLine() {
$this->line = $this->lineSaved;
}
protected function isFirstNonEmptyLine() {
$len = count($this->text);
for ($ii = 0; $ii < $len; $ii++) {
$line = $this->text[$ii];
if (!strlen(trim($line))) {
// This line is empty, skip it.
continue;
}
if (preg_match('/^#/', $line)) {
// This line is a comment, skip it.
continue;
}
return ($ii == $this->line);
}
// Entire file is empty.
return false;
}
protected function didFinishParse() {
$this->text = null;
}
public function setWriteDiffOnFailure($write) {
$this->writeDiffOnFailure = $write;
return $this;
}
protected function didFailParse($message) {
$context = 5;
$min = max(0, $this->line - $context);
$max = min($this->line + $context, count($this->text) - 1);
$context = '';
for ($ii = $min; $ii <= $max; $ii++) {
$context .= sprintf(
'%8.8s %6.6s %s',
($ii == $this->line) ? '>>> ' : '',
$ii + 1,
$this->text[$ii]);
}
$out = array();
$out[] = pht('Diff Parse Exception: %s', $message);
if ($this->writeDiffOnFailure) {
$temp = new TempFile();
$temp->setPreserveFile(true);
Filesystem::writeFile($temp, $this->rawDiff);
$out[] = pht('Raw input file was written to: %s', $temp);
}
$out[] = $context;
$out = implode("\n\n", $out);
throw new Exception($out);
}
/**
* Unescape escaped filenames, e.g. from "git diff".
*/
private static function unescapeFilename($name) {
if (preg_match('/^".+"$/', $name)) {
return stripcslashes(substr($name, 1, -1));
} else {
return $name;
}
}
private function loadSyntheticData() {
if (!$this->changes) {
return;
}
$repository_api = $this->repositoryAPI;
if (!$repository_api) {
return;
}
$imagechanges = array();
$changes = $this->changes;
foreach ($changes as $change) {
$path = $change->getCurrentPath();
// Certain types of changes (moves and copies) don't contain change data
// when expressed in raw "git diff" form. Augment any such diffs with
// textual data.
if ($change->getNeedsSyntheticGitHunks() &&
($repository_api instanceof ArcanistGitAPI)) {
$diff = $repository_api->getRawDiffText($path, $moves = false);
// NOTE: We're reusing the parser and it doesn't reset change state
// between parses because there's an oddball SVN workflow in Phabricator
// which relies on being able to inject changes.
// TODO: Fix this.
$parser = clone $this;
$parser->setChanges(array());
$raw_changes = $parser->parseDiff($diff);
foreach ($raw_changes as $raw_change) {
if ($raw_change->getCurrentPath() == $path) {
$change->setFileType($raw_change->getFileType());
foreach ($raw_change->getHunks() as $hunk) {
// Git thinks that this file has been added. But we know that it
// has been moved or copied without a change.
$hunk->setCorpus(
preg_replace('/^\+/m', ' ', $hunk->getCorpus()));
$change->addHunk($hunk);
}
break;
}
}
$change->setNeedsSyntheticGitHunks(false);
}
if ($change->getFileType() != ArcanistDiffChangeType::FILE_BINARY &&
$change->getFileType() != ArcanistDiffChangeType::FILE_IMAGE) {
continue;
}
$imagechanges[$path] = $change;
}
// Fetch the actual file contents in batches so repositories
// that have slow random file accesses (i.e. mercurial) can
// optimize the retrieval.
$paths = array_keys($imagechanges);
$filedata = $repository_api->getBulkOriginalFileData($paths);
foreach ($filedata as $path => $data) {
$imagechanges[$path]->setOriginalFileData($data);
}
$filedata = $repository_api->getBulkCurrentFileData($paths);
foreach ($filedata as $path => $data) {
$imagechanges[$path]->setCurrentFileData($data);
}
$this->changes = $changes;
}
/**
* Extracts the common filename from two strings with differing path
* prefixes as found after `diff --git`. These strings may be
* quoted; if so, the filename is returned unescaped. The prefixes
* default to "a/" and "b/", but may be any string -- or may be
* entierly absent. This function may return "null" if the hunk
* represents a file move or copy, and with pathological renames may
* return an incorrect value. Such cases are expected to be
* recovered by later rename detection codepaths.
*
- * @param string Text from a diff line after "diff --git ".
+ * @param string $paths Text from a diff line after "diff --git ".
* @return string Filename being altered, or null for a rename.
*/
public static function extractGitCommonFilename($paths) {
$matches = null;
$paths = rtrim($paths, "\r\n");
// Try the exact same string twice in a row separated by a
// space, with an optional prefix. This can hit a false
// positive for moves from files like "old file old" to "file",
// but such a cases will be caught by the "rename from" /
// "rename to" lines.
$prefix = '(?:[^/]+/)?';
$pattern =
"@^(?P<old>(?P<oldq>\"?){$prefix}(?P<common>.+)\\k<oldq>)"
." "
."(?P<new>(?P<newq>\"?){$prefix}\\k<common>\\k<newq>)$@";
if (!preg_match($pattern, $paths, $matches)) {
// A rename or some form; return null for now, and let the
// "rename from" / "rename to" lines fix it up.
return null;
}
// Use the common subpart. There may be ambiguity here: "src/file
// dst/file" may _either_ be a prefix-less move, or a change with
// two custom prefixes. We assume it is the latter; if it is a
// rename, diff parsing will update based on the "rename from" /
// "rename to" lines.
// This re-assembles with the differing prefixes removed, but the
// quoting from the original. Necessary so we know if we should
// unescape characters from the common string.
$new = $matches['newq'].$matches['common'].$matches['newq'];
$new = self::unescapeFilename($new);
return $new;
}
/**
* Strip the header and footer off a `git-format-patch` diff.
*
* Returns a parseable normal diff and a textual commit message.
*/
private function stripGitFormatPatch($diff) {
// We can parse this by splitting it into two pieces over and over again
// along different section dividers:
//
// 1. Mail headers.
// 2. ("\n\n")
// 3. Mail body.
// 4. ("---")
// 5. Diff stat section.
// 6. ("\n\n")
// 7. Actual diff body.
// 8. ("--")
// 9. Patch footer.
list($head, $tail) = preg_split('/^---$/m', $diff, 2);
list($mail_headers, $mail_body) = explode("\n\n", $head, 2);
list($body, $foot) = preg_split('/^-- ?$/m', $tail, 2);
list($stat, $diff) = explode("\n\n", $body, 2);
// Rebuild the commit message by putting the subject line back on top of it,
// if we can find one.
$matches = null;
$pattern = '/^Subject: (?:\[PATCH\] )?(.*)$/mi';
if (preg_match($pattern, $mail_headers, $matches)) {
$mail_body = $matches[1]."\n\n".$mail_body;
$mail_body = rtrim($mail_body);
}
return array($mail_body, $diff);
}
}
diff --git a/src/parser/PhutilEditorConfig.php b/src/parser/PhutilEditorConfig.php
index d2bf93a3..05d326ef 100644
--- a/src/parser/PhutilEditorConfig.php
+++ b/src/parser/PhutilEditorConfig.php
@@ -1,201 +1,201 @@
<?php
/**
* Parser for [[http://editorconfig.org/ | EditorConfig]] files.
*/
final class PhutilEditorConfig extends Phobject {
const CHARSET = 'charset';
const END_OF_LINE = 'end_of_line';
const INDENT_SIZE = 'indent_size';
const INDENT_STYLE = 'indent_style';
const FINAL_NEWLINE = 'insert_final_newline';
const LINE_LENGTH = 'max_line_length';
const TAB_WIDTH = 'tab_width';
const TRAILING_WHITESPACE = 'trim_trailing_whitespace';
/**
* Valid properties.
*
* See http://editorconfig.org/#file-format-details.
*/
private static $knownProperties = array(
self::CHARSET => array(
'latin1',
'utf-8',
'utf-8-bom',
'utf-16be',
'utf-16le',
),
self::END_OF_LINE => array('lf', 'cr', 'crlf'),
self::INDENT_SIZE => 'int|string',
self::INDENT_STYLE => array('space', 'tab'),
self::FINAL_NEWLINE => 'bool',
self::LINE_LENGTH => 'int',
self::TAB_WIDTH => 'int',
self::TRAILING_WHITESPACE => 'bool',
);
private $root;
/**
* Constructor.
*
- * @param string The root directory.
+ * @param string $root The root directory.
*/
public function __construct($root) {
$this->root = $root;
}
/**
* Get the specified EditorConfig property for the specified path.
*
- * @param string
- * @param string
+ * @param string $path
+ * @param string $key
* @return wild
*/
public function getProperty($path, $key) {
if (!idx(self::$knownProperties, $key)) {
throw new InvalidArgumentException(pht('Invalid EditorConfig property.'));
}
$props = $this->getProperties($path);
switch ($key) {
case self::INDENT_SIZE:
if (idx($props, self::INDENT_SIZE) === null &&
idx($props, self::INDENT_STYLE) === 'tab') {
return 'tab';
} else if (idx($props, self::INDENT_SIZE) === 'tab' &&
idx($props, self::TAB_WIDTH) === null) {
return idx($props, self::TAB_WIDTH);
}
break;
case self::TAB_WIDTH:
if (idx($props, self::TAB_WIDTH) === null &&
idx($props, self::INDENT_SIZE) !== null &&
idx($props, self::INDENT_SIZE) !== 'tab') {
return idx($props, self::INDENT_SIZE);
}
break;
}
return idx($props, $key);
}
/**
* Get the EditorConfig properties for the specified path.
*
* Returns a map containing all of the EditorConfig properties which apply
* to the specified path. The following rules are applied when processing
* EditorConfig files:
*
* - If a glob does not contain `/`, it can match a path in any subdirectory.
* - If the first character of a glob is `/`, it will only match files in the
* same directory as the `.editorconfig` file.
* - Properties and values are case-insensitive.
* - Unknown properties will be silently ignored.
* - Values are not validated against the specification (this may change in
* the future).
* - Invalid glob patterns will be silently ignored.
*
- * @param string
+ * @param string $path
* @return map<string, wild>
*/
public function getProperties($path) {
$configs = $this->getEditorConfigs($path);
$matches = array();
// Normalize directory separators to "/". The ".editorconfig" standard
// uses only "/" as a directory separator, not "\".
$path = str_replace(DIRECTORY_SEPARATOR, '/', $path);
foreach ($configs as $config) {
list($path_prefix, $editorconfig) = $config;
// Normalize path separators, as above.
$path_prefix = str_replace(DIRECTORY_SEPARATOR, '/', $path_prefix);
foreach ($editorconfig as $glob => $properties) {
if (!$glob) {
continue;
}
if (strpos($glob, '/') === false) {
$glob = '**/'.$glob;
} else if (strncmp($glob, '/', 0)) {
$glob = substr($glob, 1);
}
$glob = $path_prefix.'/'.$glob;
try {
if (!phutil_fnmatch($glob, $path)) {
continue;
}
} catch (Exception $ex) {
// Invalid glob pattern... ignore it.
continue;
}
foreach ($properties as $property => $value) {
$property = strtolower($property);
if (!idx(self::$knownProperties, $property)) {
// Unknown property... ignore it.
continue;
}
if (is_string($value)) {
$value = strtolower($value);
}
if ($value === '') {
$value = null;
}
$matches[$property] = $value;
}
}
}
return $matches;
}
/**
* Returns the EditorConfig files which affect the specified path.
*
* Find and parse all `.editorconfig` files between the specified path and
* the root directory. The results are returned in the same order that they
* should be matched.
*
* return list<pair<string, map>>
*/
private function getEditorConfigs($path) {
$configs = array();
$found_root = false;
$paths = Filesystem::walkToRoot($path, $this->root);
foreach ($paths as $path) {
$file = $path.'/.editorconfig';
if (!Filesystem::pathExists($file)) {
continue;
}
$contents = Filesystem::readFile($file);
$config = phutil_ini_decode($contents);
if (idx($config, 'root') === true) {
$found_root = true;
}
unset($config['root']);
array_unshift($configs, array($path, $config));
if ($found_root) {
break;
}
}
return $configs;
}
}
diff --git a/src/parser/PhutilJSON.php b/src/parser/PhutilJSON.php
index 5f43a625..dd6c2fb6 100644
--- a/src/parser/PhutilJSON.php
+++ b/src/parser/PhutilJSON.php
@@ -1,161 +1,161 @@
<?php
/**
* Utilities for wrangling JSON.
*
* @task pretty Formatting JSON Objects
* @task internal Internals
*/
final class PhutilJSON extends Phobject {
/* -( Formatting JSON Objects )-------------------------------------------- */
/**
* Encode an object in JSON and pretty-print it. This generates a valid JSON
* object with human-readable whitespace and indentation.
*
- * @param dict An object to encode in JSON.
+ * @param dict $object An object to encode in JSON.
* @return string Pretty-printed object representation.
*/
public function encodeFormatted($object) {
return $this->encodeFormattedObject($object, 0)."\n";
}
/**
* Encode a list in JSON and pretty-print it, discarding keys.
*
- * @param list<wild> List to encode in JSON.
+ * @param list<wild> $list List to encode in JSON.
* @return string Pretty-printed list representation.
*/
public function encodeAsList(array $list) {
return $this->encodeFormattedArray($list, 0)."\n";
}
/* -( Internals )---------------------------------------------------------- */
/**
* Pretty-print a JSON object.
*
- * @param dict Object to format.
- * @param int Current depth, for indentation.
+ * @param dict $object Object to format.
+ * @param int $depth Current depth, for indentation.
* @return string Pretty-printed value.
* @task internal
*/
private function encodeFormattedObject($object, $depth) {
if ($object instanceof stdClass) {
$object = (array)$object;
}
if (empty($object)) {
return '{}';
}
$pre = $this->getIndent($depth);
$key_pre = $this->getIndent($depth + 1);
$keys = array();
$vals = array();
$max = 0;
foreach ($object as $key => $val) {
$ekey = $this->encodeFormattedValue((string)$key, 0);
$max = max($max, strlen($ekey));
$keys[] = $ekey;
$vals[] = $this->encodeFormattedValue($val, $depth + 1);
}
$key_lines = array();
foreach ($keys as $k => $key) {
$key_lines[] = $key_pre.$key.': '.$vals[$k];
}
$key_lines = implode(",\n", $key_lines);
$out = "{\n";
$out .= $key_lines;
$out .= "\n";
$out .= $pre.'}';
return $out;
}
/**
* Pretty-print a JSON list.
*
- * @param list List to format.
- * @param int Current depth, for indentation.
+ * @param list $array List to format.
+ * @param int $depth Current depth, for indentation.
* @return string Pretty-printed value.
* @task internal
*/
private function encodeFormattedArray($array, $depth) {
if (empty($array)) {
return '[]';
}
$pre = $this->getIndent($depth);
$val_pre = $this->getIndent($depth + 1);
$vals = array();
foreach ($array as $val) {
$vals[] = $val_pre.$this->encodeFormattedValue($val, $depth + 1);
}
$val_lines = implode(",\n", $vals);
$out = "[\n";
$out .= $val_lines;
$out .= "\n";
$out .= $pre.']';
return $out;
}
/**
* Pretty-print a JSON value.
*
- * @param dict Value to format.
- * @param int Current depth, for indentation.
+ * @param dict $value Value to format.
+ * @param int $depth Current depth, for indentation.
* @return string Pretty-printed value.
* @task internal
*/
private function encodeFormattedValue($value, $depth) {
if (is_array($value)) {
if (phutil_is_natural_list($value)) {
return $this->encodeFormattedArray($value, $depth);
} else {
return $this->encodeFormattedObject($value, $depth);
}
} else if (is_object($value)) {
return $this->encodeFormattedObject($value, $depth);
} else {
if (defined('JSON_UNESCAPED_SLASHES')) {
// If we have a new enough version of PHP, disable escaping of slashes
// when pretty-printing values. Escaping slashes can defuse an attack
// where the attacker embeds "</script>" inside a JSON string, but that
// isn't relevant when rendering JSON for human viewers.
return json_encode($value, JSON_UNESCAPED_SLASHES);
} else {
return json_encode($value);
}
}
}
/**
* Render a string corresponding to the current indent depth.
*
- * @param int Current depth.
+ * @param int $depth Current depth.
* @return string Indentation.
* @task internal
*/
private function getIndent($depth) {
if (!$depth) {
return '';
} else {
return str_repeat(' ', $depth);
}
}
}
diff --git a/src/parser/PhutilLanguageGuesser.php b/src/parser/PhutilLanguageGuesser.php
index 7b0dc6fc..98f1664b 100644
--- a/src/parser/PhutilLanguageGuesser.php
+++ b/src/parser/PhutilLanguageGuesser.php
@@ -1,47 +1,47 @@
<?php
/**
* Very simple class to guess the languages of source files which we failed to
* determine by examining file name/extension rules.
*/
final class PhutilLanguageGuesser extends Phobject {
/**
* Guess which computer programming language a file is written in.
*
- * @param string Source text of the file.
+ * @param string $source Source text of the file.
* @return mixed Language string, or null if unable to guess.
*/
public static function guessLanguage($source) {
static $patterns = array(
// Capture "#!/usr/bin/env php" sorts of things.
'@^#!.*bin/env\s+(\S+)@' => 1,
// Capture "#!/usr/bin/php" sorts of things.
'@^#!.*bin/(\S+)@' => 1,
// Capture initial "<?php", which means PHP.
'@^\s*<[?](php)@' => 1,
// Capture emacs "mode" header.
'@^.*-[*]-.*mode\s*:\s*(\S+).*-[*]-.*$@m' => 1,
// Look for things that seem to be diffs.
'/^---.*$\n^[+]{3}.*$\n^@@/m' => 'diff',
'/^diff --git/' => 'diff',
// Look for plausible console output.
'@^(?:\S+[\\\\/] )?[$] @' => 'console',
);
foreach ($patterns as $pattern => $language) {
$matches = null;
if (preg_match($pattern, $source, $matches)) {
if (is_numeric($language)) {
return $matches[$language];
} else {
return $language;
}
}
}
return null;
}
}
diff --git a/src/parser/PhutilQueryStringParser.php b/src/parser/PhutilQueryStringParser.php
index 90f67f64..4e082537 100644
--- a/src/parser/PhutilQueryStringParser.php
+++ b/src/parser/PhutilQueryStringParser.php
@@ -1,139 +1,139 @@
<?php
/**
* Utilities for parsing HTTP query strings.
*
* The builtin functions in PHP (notably, `parse_str()` and automatic parsing
* prior to request handling) are not suitable in the general case because they
* silently convert some characters in parameter names into underscores.
*
* For example, if you call `parse_str()` with input like this:
*
* x.y=z
*
* ...the output is this:
*
* array(
* 'x_y' => 'z',
* );
*
* ...with the `.` replaced with an underscore, `_`. Other characters converted
* in this way include space and unmatched opening brackets.
*
* Broadly, this is part of the terrible legacy of `register_globals`. Since
* we'd like to be able to parse all valid query strings without destroying any
* data, this class implements a less-encumbered parser.
*/
final class PhutilQueryStringParser extends Phobject {
/**
* Parses a query string into a dictionary, applying PHP rules for handling
* array nomenclature (like `a[]=1`) in parameter names.
*
* For a more basic parse, see @{method:parseQueryStringToPairList}.
*
- * @param string Query string.
+ * @param string $query_string Query string.
* @return map<string, wild> Parsed dictionary.
*/
public function parseQueryString($query_string) {
$result = array();
$list = $this->parseQueryStringToPairList($query_string);
foreach ($list as $parts) {
list($key, $value) = $parts;
if (!strlen($key)) {
continue;
}
$this->parseQueryKeyToArr($key, $value, $result);
}
return $result;
}
/**
* Parses a query string into a basic list of pairs, without handling any
* array information in the keys. For example:
*
* a[]=1&a[]=2
*
* ...will parse into:
*
* array(
* array('a[]', '1'),
* array('a[]', '2'),
* );
*
* Use @{method:parseQueryString} to produce a more sophisticated parse which
* applies array rules and returns a dictionary.
*
- * @param string Query string.
+ * @param string $query_string Query string.
* @return list<pair<string, string>> List of parsed parameters.
*/
public function parseQueryStringToPairList($query_string) {
$list = array();
if (!strlen($query_string)) {
return $list;
}
$pairs = explode('&', $query_string);
foreach ($pairs as $pair) {
if (!strlen($pair)) {
continue;
}
$parts = explode('=', $pair, 2);
if (count($parts) < 2) {
$parts[] = '';
}
$list[] = array(
urldecode($parts[0]),
urldecode($parts[1]),
);
}
return $list;
}
/**
* Treats the key as a flat query that potentially has square brackets. If
* there are square brackets we parse them into an array.
*
* Example input:
* $key = "email[0]";
* $val = "my@example.com";
*
* Example output:
* array("email" => array(0 => "my@example.com"));
*
* @param string $key
* @param string $val
- * @param array $input_arr
+ * @param array &$input_arr
*/
private function parseQueryKeyToArr($key, $val, array &$input_arr) {
if (preg_match('/^[^\[\]]+(?:\[[^\[\]]*\])+$/', $key)) {
$key_pieces = preg_split('/\]?\[/', rtrim($key, ']'));
if ($key_pieces) {
$cursor = &$input_arr;
foreach ($key_pieces as $piece) {
if (strlen($piece)) {
if (empty($cursor[$piece]) || !is_array($cursor[$piece])) {
$cursor[$piece] = array();
}
} else {
$cursor[] = array();
$piece = last_key($cursor);
}
$cursor = &$cursor[$piece];
}
$cursor = $val;
unset($cursor);
}
} else {
$input_arr[$key] = $val;
}
}
}
diff --git a/src/parser/PhutilSimpleOptions.php b/src/parser/PhutilSimpleOptions.php
index bcb25a4c..c763051b 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 option list.
+ * @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 Input dictionary.
- * @param string Additional characters to escape.
+ * @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 True to make the parser case sensitive, false (default) to
- * make it case-insensitive.
+ * @param bool $case_sensitive True to make the parser case sensitive, false
+ * to make it case-insensitive.
* @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/aast/api/AASTNode.php b/src/parser/aast/api/AASTNode.php
index 7b20224a..db25b029 100644
--- a/src/parser/aast/api/AASTNode.php
+++ b/src/parser/aast/api/AASTNode.php
@@ -1,404 +1,404 @@
<?php
abstract class AASTNode extends Phobject {
private $id;
protected $l;
protected $r;
private $typeID;
private $typeName;
protected $tree;
private $children = array();
private $parentNode = null;
private $previousSibling = null;
private $nextSibling = null;
private $selectCache;
private $tokenCache;
abstract public function isStaticScalar();
abstract public function getDocblockToken();
abstract public function evalStatic();
abstract public function getStringLiteralValue();
public function __construct($id, array $data, AASTTree $tree) {
$this->id = $id;
$this->typeID = $data[0];
if (isset($data[1])) {
$this->l = $data[1];
} else {
$this->l = -1;
}
if (isset($data[2])) {
$this->r = $data[2];
} else {
$this->r = -1;
}
$this->tree = $tree;
}
final public function getParentNode() {
return $this->parentNode;
}
final public function setParentNode(AASTNode $node = null) {
$this->parentNode = $node;
return $this;
}
final public function getPreviousSibling() {
return $this->previousSibling;
}
final public function setPreviousSibling(AASTNode $node = null) {
$this->previousSibling = $node;
return $this;
}
final public function getNextSibling() {
return $this->nextSibling;
}
final public function setNextSibling(AASTNode $node = null) {
$this->nextSibling = $node;
return $this;
}
final public function getID() {
return $this->id;
}
final public function getTypeID() {
return $this->typeID;
}
final public function getTree() {
return $this->tree;
}
final public function getTypeName() {
if (empty($this->typeName)) {
$this->typeName =
$this->tree->getNodeTypeNameFromTypeID($this->getTypeID());
}
return $this->typeName;
}
final public function getChildren() {
return $this->children;
}
final public function setChildren(array $children) {
// We don't call `assert_instances_of($children, 'AASTNode')` because doing
// so would incur a significant performance penalty.
$this->children = $children;
return $this;
}
public function getChildrenOfType($type) {
$nodes = array();
foreach ($this->children as $child) {
if ($child->getTypeName() == $type) {
$nodes[] = $child;
}
}
return $nodes;
}
public function getChildOfType($index, $type) {
$child = $this->getChildByIndex($index);
if ($child->getTypeName() != $type) {
throw new Exception(
pht(
"Child in position '%d' is not of type '%s': %s",
$index,
$type,
$this->getDescription()));
}
return $child;
}
public function getChildByIndex($index) {
// NOTE: Microoptimization to avoid calls like array_values() or idx().
$idx = 0;
foreach ($this->children as $child) {
if ($idx == $index) {
return $child;
}
++$idx;
}
throw new Exception(pht("No child with index '%d'.", $index));
}
/**
* Build a cache to improve the performance of
* @{method:selectDescendantsOfType}. This cache makes a time/memory tradeoff
* by aggressively caching node descendants. It may improve the tree's query
* performance substantially if you make a large number of queries, but also
* requires a significant amount of memory.
*
* This builds a cache for the entire tree and improves performance of all
* @{method:selectDescendantsOfType} calls.
*/
public function buildSelectCache() {
$cache = array();
foreach ($this->getChildren() as $id => $child) {
$type_id = $child->getTypeID();
if (empty($cache[$type_id])) {
$cache[$type_id] = array();
}
$cache[$type_id][$id] = $child;
foreach ($child->buildSelectCache() as $type_id => $nodes) {
if (empty($cache[$type_id])) {
$cache[$type_id] = array();
}
$cache[$type_id] += $nodes;
}
}
$this->selectCache = $cache;
return $this->selectCache;
}
/**
* Build a cache to improve the performance of @{method:selectTokensOfType}.
* This cache makes a time/memory tradeoff by aggressively caching token
* types. It may improve the tree's query performance substantially if you
* make a large number of queries, but also requires a significant amount of
* memory.
*
* This builds a cache for this node only.
*/
public function buildTokenCache() {
$cache = array();
foreach ($this->getTokens() as $id => $token) {
$cache[$token->getTypeName()][$id] = $token;
}
$this->tokenCache = $cache;
return $this->tokenCache;
}
public function selectTokensOfType($type_name) {
return $this->selectTokensOfTypes(array($type_name));
}
/**
* Select all tokens of any given types.
*/
public function selectTokensOfTypes(array $type_names) {
$tokens = array();
foreach ($type_names as $type_name) {
if (isset($this->tokenCache)) {
$cached_tokens = idx($this->tokenCache, $type_name, array());
foreach ($cached_tokens as $id => $cached_token) {
$tokens[$id] = $cached_token;
}
} else {
foreach ($this->getTokens() as $id => $token) {
if ($token->getTypeName() == $type_name) {
$tokens[$id] = $token;
}
}
}
}
return $tokens;
}
final public function isDescendantOf(AASTNode $node) {
for ($it = $this; $it !== null; $it = $it->getParentNode()) {
if ($it === $node) {
return true;
}
}
return false;
}
public function selectDescendantsOfType($type_name) {
return $this->selectDescendantsOfTypes(array($type_name));
}
public function selectDescendantsOfTypes(array $type_names) {
$nodes = array();
foreach ($type_names as $type_name) {
$type = $this->getTypeIDFromTypeName($type_name);
if (isset($this->selectCache)) {
if (isset($this->selectCache[$type])) {
$nodes = $nodes + $this->selectCache[$type];
}
} else {
$nodes = $nodes + $this->executeSelectDescendantsOfType($this, $type);
}
}
return AASTNodeList::newFromTreeAndNodes($this->tree, $nodes);
}
protected function executeSelectDescendantsOfType($node, $type) {
$results = array();
foreach ($node->getChildren() as $id => $child) {
if ($child->getTypeID() == $type) {
$results[$id] = $child;
}
$results += $this->executeSelectDescendantsOfType($child, $type);
}
return $results;
}
public function getTokens() {
if ($this->l == -1 || $this->r == -1) {
return array();
}
$tokens = $this->tree->getRawTokenStream();
$result = array();
foreach (range($this->l, $this->r) as $token_id) {
$result[$token_id] = $tokens[$token_id];
}
return $result;
}
public function getConcreteString() {
$values = array();
foreach ($this->getTokens() as $token) {
$values[] = $token->getValue();
}
return implode('', $values);
}
public function getSemanticString() {
$tokens = $this->getTokens();
foreach ($tokens as $id => $token) {
if ($token->isComment()) {
unset($tokens[$id]);
}
}
return implode('', mpull($tokens, 'getValue'));
}
public function getIndentation() {
$tokens = $this->getTokens();
$left = head($tokens);
while ($left &&
(!$left->isAnyWhitespace() ||
strpos($left->getValue(), "\n") === false)) {
$left = $left->getPrevToken();
}
if (!$left) {
return null;
}
return preg_replace("/^.*\n/s", '', $left->getValue());
}
public function getDescription() {
$concrete = $this->getConcreteString();
if (strlen($concrete) > 75) {
$concrete = substr($concrete, 0, 36).'...'.substr($concrete, -36);
}
$concrete = addcslashes($concrete, "\\\n\"");
return pht('a node of type %s: "%s"', $this->getTypeName(), $concrete);
}
final protected function getTypeIDFromTypeName($type_name) {
return $this->tree->getNodeTypeIDFromTypeName($type_name);
}
final public function getOffset() {
$stream = $this->tree->getRawTokenStream();
if (empty($stream[$this->l])) {
return null;
}
return $stream[$this->l]->getOffset();
}
final public function getLength() {
$stream = $this->tree->getRawTokenStream();
if (empty($stream[$this->r])) {
return null;
}
return $stream[$this->r]->getOffset() - $this->getOffset();
}
public function getSurroundingNonsemanticTokens() {
$before = array();
$after = array();
$tokens = $this->tree->getRawTokenStream();
if ($this->l != -1) {
$before = $tokens[$this->l]->getNonsemanticTokensBefore();
}
if ($this->r != -1) {
$after = $tokens[$this->r]->getNonsemanticTokensAfter();
}
return array($before, $after);
}
final public function getLineNumber() {
return idx($this->tree->getOffsetToLineNumberMap(), $this->getOffset());
}
final public function getEndLineNumber() {
return idx(
$this->tree->getOffsetToLineNumberMap(),
$this->getOffset() + $this->getLength());
}
/**
* Determines whether the current node appears //after// a specified node in
* the tree.
*
- * @param AASTNode
+ * @param AASTNode $node
* @return bool
*/
final public function isAfter(AASTNode $node) {
return head($this->getTokens())->getOffset() >
last($node->getTokens())->getOffset();
}
/**
* Determines whether the current node appears //before// a specified node in
* the tree.
*
- * @param AASTNode
+ * @param AASTNode $node
* @return bool
*/
final public function isBefore(AASTNode $node) {
return last($this->getTokens())->getOffset() <
head($node->getTokens())->getOffset();
}
/**
* Determines whether a specified node is a descendant of the current node.
*
- * @param AASTNode
+ * @param AASTNode $node
* @return bool
*/
final public function containsDescendant(AASTNode $node) {
return !$this->isAfter($node) && !$this->isBefore($node);
}
public function dispose() {
foreach ($this->getChildren() as $child) {
$child->dispose();
}
unset($this->selectCache);
}
}
diff --git a/src/parser/argument/PhutilArgumentParser.php b/src/parser/argument/PhutilArgumentParser.php
index 6391fd83..fccb8e9d 100644
--- a/src/parser/argument/PhutilArgumentParser.php
+++ b/src/parser/argument/PhutilArgumentParser.php
@@ -1,1044 +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 Argument vector to parse, generally the $argv global.
+ * @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 List of argument specs, see
+ * @param list $specs List of argument specs, see
* @{class:PhutilArgumentSpecification}.
- * @param bool Require flags appear before any non-flag arguments.
+ * @param bool $initial_only (optional) Require flags appear before any
+ * non-flag arguments.
* @return this
* @task parse
*/
public function parsePartial(array $specs, $initial_only = false) {
return $this->parseInternal($specs, false, $initial_only);
}
/**
* @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 List of argument specs, see
+ * @param list $specs List of argument specs, see
* @{class:PhutilArgumentSpecification}.
* @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 List of argument specs, see
+ * @param list $specs List of argument specs, see
* @{class:PhutilArgumentSpecification}.
* @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 List of argument specs, see
+ * @param list $workflows List of argument specs, see
* @{class:PhutilArgumentSpecification}.
* @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 List of @{class:PhutilArgumentWorkflow}s.
+ * @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
*
* @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/parser/argument/PhutilArgumentSpecification.php b/src/parser/argument/PhutilArgumentSpecification.php
index 71e583b1..fca3143f 100644
--- a/src/parser/argument/PhutilArgumentSpecification.php
+++ b/src/parser/argument/PhutilArgumentSpecification.php
@@ -1,268 +1,268 @@
<?php
final class PhutilArgumentSpecification extends Phobject {
private $name;
private $help;
private $shortAlias;
private $paramName;
private $default;
private $conflicts = array();
private $wildcard;
private $repeatable;
private $standard;
/**
* Convenience constructor for building an argument specification from a
* dictionary. This just wraps all the setter methods, but allows you to
* define things a little more compactly. Pass an array of properties:
*
* $spec = PhutilArgumentSpecification::newQuickSpec(
* array(
* 'name' => 'verbose',
* 'short' => 'v',
* ));
*
* Recognized keys and equivalent verbose methods are:
*
* name setName()
* help setHelp()
* short setShortAlias()
* param setParamName()
* default setDefault()
* conflicts setConflicts()
* wildcard setWildcard()
* repeat setRepeatable()
*
- * @param dict Dictionary of quick parameter definitions.
+ * @param dict $spec Dictionary of quick parameter definitions.
* @return PhutilArgumentSpecification Constructed argument specification.
*/
public static function newQuickSpec(array $spec) {
$recognized_keys = array(
'name',
'help',
'short',
'param',
'default',
'conflicts',
'wildcard',
'repeat',
'standard',
);
$unrecognized = array_diff_key(
$spec,
array_fill_keys($recognized_keys, true));
foreach ($unrecognized as $key => $ignored) {
throw new PhutilArgumentSpecificationException(
pht(
"Unrecognized key '%s' in argument specification. Recognized keys ".
"are: %s.",
$key,
implode(', ', $recognized_keys)));
}
$obj = new PhutilArgumentSpecification();
foreach ($spec as $key => $value) {
switch ($key) {
case 'name':
$obj->setName($value);
break;
case 'help':
$obj->setHelp($value);
break;
case 'short':
$obj->setShortAlias($value);
break;
case 'param':
$obj->setParamName($value);
break;
case 'default':
$obj->setDefault($value);
break;
case 'conflicts':
$obj->setConflicts($value);
break;
case 'wildcard':
$obj->setWildcard($value);
break;
case 'repeat':
$obj->setRepeatable($value);
break;
case 'standard':
$obj->setStandard($value);
break;
}
}
$obj->validate();
return $obj;
}
public static function newSpecsFromList(array $specs) {
foreach ($specs as $key => $spec) {
if (is_array($spec)) {
$specs[$key] = self::newQuickSpec(
$spec);
}
}
return $specs;
}
public function setName($name) {
self::validateName($name);
$this->name = $name;
return $this;
}
private static function validateName($name) {
if (!preg_match('/^[a-z0-9][a-z0-9-]*$/', $name)) {
throw new PhutilArgumentSpecificationException(
pht(
"Argument names may only contain a-z, 0-9 and -, and must be ".
"at least one character long. '%s' is invalid.",
$name));
}
}
public function getName() {
return $this->name;
}
public function setHelp($help) {
$this->help = $help;
return $this;
}
public function getHelp() {
return $this->help;
}
public function setShortAlias($short_alias) {
self::validateShortAlias($short_alias);
$this->shortAlias = $short_alias;
return $this;
}
private static function validateShortAlias($alias) {
if (strlen($alias) !== 1) {
throw new PhutilArgumentSpecificationException(
pht(
"Argument short aliases must be exactly one character long. ".
"'%s' is invalid.",
$alias));
}
if (!preg_match('/^[a-zA-Z0-9]$/', $alias)) {
throw new PhutilArgumentSpecificationException(
pht(
"Argument short aliases may only be in a-z, A-Z and 0-9. ".
"'%s' is invalid.",
$alias));
}
}
public function getShortAlias() {
return $this->shortAlias;
}
public function setParamName($param_name) {
$this->paramName = $param_name;
return $this;
}
public function getParamName() {
return $this->paramName;
}
public function setDefault($default) {
$this->default = $default;
return $this;
}
public function getDefault() {
if ($this->getParamName() === null) {
if ($this->getRepeatable()) {
return 0;
} else {
return false;
}
} else {
if ($this->getRepeatable()) {
return array();
} else {
return $this->default;
}
}
}
public function setConflicts(array $conflicts) {
$this->conflicts = $conflicts;
return $this;
}
public function getConflicts() {
return $this->conflicts;
}
public function setWildcard($wildcard) {
$this->wildcard = $wildcard;
return $this;
}
public function getWildcard() {
return $this->wildcard;
}
public function setRepeatable($repeatable) {
$this->repeatable = $repeatable;
return $this;
}
public function getRepeatable() {
return $this->repeatable;
}
public function setStandard($standard) {
$this->standard = $standard;
return $this;
}
public function getStandard() {
return $this->standard;
}
public function validate() {
if ($this->name === null) {
throw new PhutilArgumentSpecificationException(
pht("Argument specification MUST have a 'name'."));
}
if ($this->getWildcard()) {
if ($this->getParamName()) {
throw new PhutilArgumentSpecificationException(
pht('Wildcard arguments may not specify a parameter.'));
}
if ($this->getRepeatable()) {
throw new PhutilArgumentSpecificationException(
pht('Wildcard arguments may not be repeatable.'));
}
}
if ($this->default !== null) {
if ($this->getRepeatable()) {
throw new PhutilArgumentSpecificationException(
pht(
'Repeatable arguments may not have a default (always array() for '.
'arguments which accept a parameter, or 0 for arguments which do '.
'not).'));
} else if ($this->getParamName() === null) {
throw new PhutilArgumentSpecificationException(
pht('Flag arguments may not have a default (always false).'));
}
}
}
}
diff --git a/src/parser/xhpast/api/XHPASTNode.php b/src/parser/xhpast/api/XHPASTNode.php
index 0f685c5e..94c095dd 100644
--- a/src/parser/xhpast/api/XHPASTNode.php
+++ b/src/parser/xhpast/api/XHPASTNode.php
@@ -1,342 +1,341 @@
<?php
final class XHPASTNode extends AASTNode {
public function isStaticScalar() {
return in_array($this->getTypeName(), array(
'n_STRING_SCALAR',
'n_NUMERIC_SCALAR',
));
}
public function getDocblockToken() {
if ($this->l == -1) {
return null;
}
$tokens = $this->tree->getRawTokenStream();
for ($ii = $this->l - 1; $ii >= 0; $ii--) {
if ($tokens[$ii]->getTypeName() == 'T_DOC_COMMENT') {
return $tokens[$ii];
}
if (!$tokens[$ii]->isAnyWhitespace()) {
return null;
}
}
return null;
}
public function evalStatic() {
switch ($this->getTypeName()) {
case 'n_STATEMENT':
return $this->getChildByIndex(0)->evalStatic();
break;
case 'n_STRING_SCALAR':
return phutil_string_cast($this->getStringLiteralValue());
case 'n_HEREDOC':
return phutil_string_cast($this->getStringLiteralValue());
case 'n_NUMERIC_SCALAR':
$value = $this->getSemanticString();
if (preg_match('/^0x/i', $value)) {
// Hex
$value = base_convert(substr($value, 2), 16, 10);
} else if (preg_match('/^0\d+$/i', $value)) {
// Octal
$value = base_convert(substr($value, 1), 8, 10);
}
return +$value;
case 'n_SYMBOL_NAME':
$value = $this->getSemanticString();
if ($value == 'INF') {
return INF;
}
switch (strtolower($value)) {
case 'true':
return true;
case 'false':
return false;
case 'null':
return null;
default:
throw new Exception(pht('Unrecognized symbol name.'));
}
break;
case 'n_UNARY_PREFIX_EXPRESSION':
$operator = $this->getChildOfType(0, 'n_OPERATOR');
$operand = $this->getChildByIndex(1);
switch ($operator->getSemanticString()) {
case '-':
return -$operand->evalStatic();
break;
case '+':
return $operand->evalStatic();
break;
default:
throw new Exception(
pht('Unexpected operator in static expression.'));
}
break;
case 'n_ARRAY_LITERAL':
$result = array();
$values = $this->getChildOfType(0, 'n_ARRAY_VALUE_LIST');
foreach ($values->getChildren() as $child) {
$key = $child->getChildByIndex(0);
$val = $child->getChildByIndex(1);
if ($key->getTypeName() == 'n_EMPTY') {
$result[] = $val->evalStatic();
} else {
$result[$key->evalStatic()] = $val->evalStatic();
}
}
return $result;
case 'n_CONCATENATION_LIST':
$result = '';
foreach ($this->getChildren() as $child) {
if ($child->getTypeName() == 'n_OPERATOR') {
continue;
}
$result .= $child->evalStatic();
}
return $result;
default:
throw new Exception(
pht(
'Unexpected node during static evaluation, of type: %s',
$this->getTypeName()));
}
}
public function isConstantString() {
return $this->checkIsConstantString();
}
public function isConstantStringWithMagicConstants() {
return $this->checkIsConstantString(array('n_MAGIC_SCALAR'));
}
private function checkIsConstantString(array $additional_types = array()) {
switch ($this->getTypeName()) {
case 'n_HEREDOC':
case 'n_STRING_SCALAR':
return !$this->getStringVariables();
case 'n_CONCATENATION_LIST':
foreach ($this->getChildren() as $child) {
if ($child->getTypeName() == 'n_OPERATOR') {
continue;
}
if (!$child->checkIsConstantString($additional_types)) {
return false;
}
}
return true;
default:
if (in_array($this->getTypeName(), $additional_types)) {
return true;
}
return false;
}
}
public function getStringVariables() {
$value = $this->getConcreteString();
switch ($this->getTypeName()) {
case 'n_HEREDOC':
if (preg_match("/^<<<\s*'/", $value)) { // Nowdoc: <<<'EOT'
return array();
}
break;
case 'n_STRING_SCALAR':
if ($value[0] == "'") {
return array();
}
break;
default:
throw new Exception(pht('Unexpected type %s.', $this->getTypeName()));
}
// We extract just the variable names and ignore properties and array keys.
$re = '/\\\\.|(\$|\{\$|\${)([a-z_\x7F-\xFF][a-z0-9_\x7F-\xFF]*)/i';
$matches = null;
preg_match_all($re, $value, $matches, PREG_OFFSET_CAPTURE);
// NOTE: The result format for this construction changed in PHP 7.4.
// See T13518.
$names = $matches[2];
foreach ($names as $name_idx => $name_match) {
if ($name_match === '') {
unset($names[$name_idx]);
continue;
}
if ($name_match[1] === -1) {
unset($names[$name_idx]);
continue;
}
}
$names = ipull($names, 0, 1);
return $names;
}
public function getStringLiteralValue() {
$type_name = $this->getTypeName();
if ($type_name === 'n_HEREDOC') {
$value = $this->getSemanticString();
$value = phutil_split_lines($value);
$value = array_slice($value, 1, -1);
$value = implode('', $value);
// Strip the final newline from value, this isn't part of the string
// literal.
$value = preg_replace('/(\r|\n|\r\n)\z/', '', $value);
return $this->newStringLiteralFromSemanticString($value);
}
if ($type_name === 'n_STRING_SCALAR') {
$value = $this->getSemanticString();
$type = $value[0];
$value = preg_replace('/^b?[\'"]|[\'"]$/i', '', $value);
if ($type == "'") {
// Single quoted strings treat everything as a literal except "\\" and
// "\'".
return str_replace(
array('\\\\', '\\\''),
array('\\', "'"),
$value);
}
return $this->newStringLiteralFromSemanticString($value);
}
return null;
}
private function newStringLiteralFromSemanticString($value) {
// Double quoted strings treat "\X" as a literal if X isn't specifically
// a character which needs to be escaped -- e.g., "\q" and "\'" are
// literally "\q" and "\'". stripcslashes() is too aggressive, so find
// all these under-escaped backslashes and escape them.
$len = strlen($value);
$esc = false;
$out = '';
for ($ii = 0; $ii < $len; $ii++) {
$c = $value[$ii];
if ($esc) {
$esc = false;
switch ($c) {
case 'x':
$u = isset($value[$ii + 1]) ? $value[$ii + 1] : null;
if (!preg_match('/^[a-f0-9]/i', $u)) {
// PHP treats \x followed by anything which is not a hex digit
// as a literal \x.
$out .= '\\\\'.$c;
break;
}
/* fallthrough */
case 'n':
case 'r':
case 'f':
case 'v':
case '"':
case '$':
case 't':
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
$out .= '\\'.$c;
break;
case 'e':
// Since PHP 5.4.0, this means "esc". However, stripcslashes() does
// not perform this conversion.
$out .= chr(27);
break;
default:
$out .= '\\\\'.$c;
break;
}
} else if ($c == '\\') {
$esc = true;
} else {
$out .= $c;
}
}
return stripcslashes($out);
}
/**
* Determines the parent namespace for a node.
*
* Traverses the AST upwards from a given node in order to determine the
* namespace in which the node is declared.
*
* To prevent any possible ambiguity, the returned namespace will always be
* prefixed with the namespace separator.
*
- * @param XHPASTNode The input node.
* @return string|null The namespace which contains the input node, or
* `null` if no such node exists.
*/
public function getNamespace() {
$namespaces = $this
->getTree()
->getRootNode()
->selectDescendantsOfType('n_NAMESPACE')
->getRawNodes();
foreach (array_reverse($namespaces) as $namespace) {
if ($namespace->isAfter($this)) {
continue;
}
$body = $namespace->getChildByIndex(1);
if ($body->getTypeName() != 'n_EMPTY') {
if (!$body->containsDescendant($this)) {
continue;
}
}
return $namespace->getNamespaceName();
}
return null;
}
/**
* Returns the namespace name from a node of type `n_NAMESPACE`.
*
* @return string|null
*/
private function getNamespaceName() {
if ($this->getTypeName() != 'n_NAMESPACE') {
return null;
}
$namespace_name = $this->getChildByIndex(0);
if ($namespace_name->getTypeName() == 'n_EMPTY') {
return null;
}
return '\\'.$namespace_name->getConcreteString();
}
}
diff --git a/src/parser/xhpast/api/__tests__/XHPASTNodeTestCase.php b/src/parser/xhpast/api/__tests__/XHPASTNodeTestCase.php
index 8e4ecbb1..7dfdc6b3 100644
--- a/src/parser/xhpast/api/__tests__/XHPASTNodeTestCase.php
+++ b/src/parser/xhpast/api/__tests__/XHPASTNodeTestCase.php
@@ -1,101 +1,101 @@
<?php
final class XHPASTNodeTestCase extends PhutilTestCase {
public function testGetStringVariables() {
$this->assertStringVariables(array(), '""');
$this->assertStringVariables(array(2 => 'abc'), '"$abc"');
$this->assertStringVariables(array(), '"\$abc"');
$this->assertStringVariables(array(2 => 'a'), '"$a[1]"');
$this->assertStringVariables(array(3 => 'a'), '"{$a[1]}"');
$this->assertStringVariables(array(2 => 'a', 5 => 'a'), '"$a $a"');
$this->assertStringVariables(array(), "''");
$this->assertStringVariables(array(), "'\$a'");
$this->assertStringVariables(array(), "<<<EOT\n\nEOT");
$this->assertStringVariables(array(8 => 'a'), "<<<EOT\n\$a\nEOT");
$this->assertStringVariables(array(), "<<<'EOT'\n\$a\nEOT");
}
private function assertStringVariables($expected, $string) {
$statement = XHPASTTree::newStatementFromString($string);
$this->assertEqual(
$expected,
$statement->getChildByIndex(0)->getStringVariables(),
$string);
}
public function testGetNamespace() {
$dir = dirname(__FILE__).'/namespace/';
$files = id(new FileFinder($dir))
->withType('f')
->withSuffix('php.test')
->find();
foreach ($files as $file) {
list($tree, $expect) = $this->readTestData($dir.'/'.$file);
$root = $tree->getRootNode();
$classes = $root->selectDescendantsOfType('n_CLASS_DECLARATION');
foreach ($classes as $class) {
$id = (string)$class->getID();
if (idx($expect, $id, false) === false) {
throw new Exception(
pht(
'No expected value for node %d in file "%s".',
$class->getID(),
$file));
}
$this->assertEqual(
$expect[$id],
$class->getNamespace());
}
}
}
/**
* Reads and parses test data from a specified file.
*
* This method reads and parses test data from a file. The file is expected
* to have the following structure
*
* ```
* <?php
* // PHP code goes here.
* ~~~~~~~~~~
* {
* // JSON dictionary containing expected results from testing method.
* }
* ```
*
- * @param string The path to the test file.
+ * @param string $file The path to the test file.
* @return pair<XHPASTTree, map> The first element of the pair is the
* `XHPASTTree` contained within the test file.
* The second element of the pair is the
* "expect" data.
*/
private function readTestData($file) {
$contents = Filesystem::readFile($file);
$contents = preg_split('/^~{10}$/m', $contents);
if (count($contents) < 2) {
throw new Exception(
pht(
"Expected '%s' separating test case and results.",
'~~~~~~~~~~'));
}
list($data, $expect) = $contents;
$tree = XHPASTTree::newFromData($data);
$expect = phutil_json_decode($expect);
return array($tree, $expect);
}
}
diff --git a/src/parser/xhpast/bin/PhutilXHPASTBinary.php b/src/parser/xhpast/bin/PhutilXHPASTBinary.php
index 5a1a4a13..058ff03c 100644
--- a/src/parser/xhpast/bin/PhutilXHPASTBinary.php
+++ b/src/parser/xhpast/bin/PhutilXHPASTBinary.php
@@ -1,134 +1,134 @@
<?php
final class PhutilXHPASTBinary extends Phobject {
/**
* The expected XHPAST version.
*
* This is the version that would be obtained with an up-to-date XHPAST
* build. The //actual// XHPAST build version may vary.
*/
const EXPECTED_VERSION = '7.1.6';
/**
* The XHPAST build version.
*
* Cache the result from @{method:getVersion} to prevent excessive calls to
* @{function:execx}.
*/
private static $version;
/**
* Builds XHPAST automatically.
*
* Attempts to build the XHPAST binary automatically.
*
* @return void
*/
public static function build() {
if (Filesystem::binaryExists('gmake')) {
$command = 'gmake';
} else {
$command = 'make';
}
$root = phutil_get_library_root('arcanist');
$path = Filesystem::resolvePath($root.'/../support/xhpast');
// Run the build.
execx(
'%s -C %s %Ls',
$command,
$path,
array('SKIP_PARSER=1', 'SKIP_SCANNER=1', 'clean', 'all', 'install'));
self::$version = null;
// Test the binary.
if (!self::isAvailable()) {
throw new Exception(pht('%s is broken.', 'xhpast'));
}
}
/**
* Returns human-readable instructions for building XHPAST.
*
* @return string
*/
public static function getBuildInstructions() {
$root = phutil_get_library_root('arcanist');
$make = Filesystem::resolvePath($root.'/../support/xhpast/build-xhpast.php');
return phutil_console_format(
"%s:\n\n \$ %s\n",
pht(
"Your version of '%s' is unbuilt or out of date. Run this ".
"script to build it.",
'xhpast'),
$make);
}
/**
* Constructs an @{class:ExecFuture} for XHPAST.
*
- * @param wild Data to pass to the future.
+ * @param wild $data Data to pass to the future.
* @return ExecFuture
*/
public static function getParserFuture($data) {
if (!self::isAvailable()) {
try {
// Try to build XHPAST automatically. If we can't then just ask the
// user to build it themselves.
self::build();
} catch (CommandException $ex) {
throw new PhutilProxyException(self::getBuildInstructions(), $ex);
}
}
$future = new ExecFuture('%s', self::getPath());
$future->write($data);
return $future;
}
/**
* Returns the path to the XHPAST binary.
*
* @return string
*/
public static function getPath() {
if (phutil_is_windows()) {
return dirname(__FILE__).'\\xhpast.exe';
}
return dirname(__FILE__).'/xhpast';
}
/**
* Returns the XHPAST version.
*
* @return string
*/
public static function getVersion() {
if (self::$version === null) {
$bin = self::getPath();
if (Filesystem::pathExists($bin)) {
list($err, $stdout) = exec_manual('%s --version', $bin);
if (!$err) {
self::$version = trim($stdout);
}
}
}
return self::$version;
}
/**
* Checks if XHPAST is built and up-to-date.
*
* @return bool
*/
public static function isAvailable() {
return self::getVersion() == self::EXPECTED_VERSION;
}
}
diff --git a/src/readableserializer/PhutilReadableSerializer.php b/src/readableserializer/PhutilReadableSerializer.php
index 7ef9d844..d4362149 100644
--- a/src/readableserializer/PhutilReadableSerializer.php
+++ b/src/readableserializer/PhutilReadableSerializer.php
@@ -1,189 +1,192 @@
<?php
/**
* Serialize PHP values and objects into a human-readable format, useful for
* displaying errors.
*
* @task print Printing PHP Values
* @task internal Internals
*/
final class PhutilReadableSerializer extends Phobject {
/* -( Printing PHP Values )------------------------------------------------ */
/**
* Given a value, makes the best attempt at returning a string representation
* of that value suitable for printing. This method returns a //complete//
* representation of the value; use @{method:printShort} or
* @{method:printShallow} to summarize values.
*
- * @param wild Any value.
+ * @param wild $value Any value.
* @return string Human-readable representation of the value.
* @task print
*/
public static function printableValue($value) {
if ($value === null) {
return 'null';
} else if ($value === false) {
return 'false';
} else if ($value === true) {
return 'true';
} else if (is_float($value) && (int)$value == $value) {
return $value.'.0';
} else if (is_string($value)) {
return "'".$value."'";
} else {
return print_r($value, true);
}
}
/**
* Print a concise, human readable representation of a value.
*
- * @param wild Any value.
+ * @param wild $value Any value.
* @return string Human-readable short representation of the value.
* @task print
*/
public static function printShort($value) {
if (is_object($value)) {
return 'Object '.get_class($value);
} else if (is_array($value)) {
$str = 'Array ';
if ($value) {
if (count($value) > 1) {
$str .= 'of size '.count($value).' starting with: ';
}
reset($value); // Prevent key() from giving warning message in HPHP.
$str .= '{ '.self::printShort(key($value)).' => '.
self::printShort(head($value)).' }';
}
return $str;
} else {
// NOTE: Avoid PhutilUTF8StringTruncator here since the data may not be
// UTF8 anyway, it's slow for large inputs, and it might not be loaded
// yet.
$limit = 1024;
$str = self::printableValue($value);
if (strlen($str) > $limit) {
if (is_string($value)) {
$str = "'".substr($str, 1, $limit)."...'";
} else {
$str = substr($str, 0, $limit).'...';
}
}
return $str;
}
}
/**
* Dump some debug output about an object's members without the
* potential recursive explosion of verbosity that comes with `print_r()`.
*
* To print any number of member variables, pass null for `$max_members`.
*
- * @param wild Any value.
- * @param int Maximum depth to print for nested arrays and objects.
- * @param int Maximum number of values to print at each level.
+ * @param wild $value Any value.
+ * @param int $max_depth (optional) Maximum depth to print for nested arrays
+ * and objects. Defaults to 2.
+ * @param int $max_members (optional) Maximum number of values to print at
+ * each level. Defaults to 25.
* @return string Human-readable shallow representation of the value.
* @task print
*/
public static function printShallow(
$value,
$max_depth = 2,
$max_members = 25) {
return self::printShallowRecursive($value, $max_depth, $max_members, 0, '');
}
/* -( Internals )---------------------------------------------------------- */
/**
* Implementation for @{method:printShallow}.
*
- * @param wild Any value.
- * @param int Maximum depth to print for nested arrays and objects.
- * @param int Maximum number of values to print at each level.
- * @param int Current depth.
- * @param string Indentation string.
+ * @param wild $value Any value.
+ * @param int $max_depth Maximum depth to print for nested arrays and
+ * objects.
+ * @param int $max_members Maximum number of values to print at each level.
+ * @param int $depth Current depth.
+ * @param string $indent Indentation string.
* @return string Human-readable shallow representation of the value.
* @task internal
*/
private static function printShallowRecursive(
$value,
$max_depth,
$max_members,
$depth,
$indent) {
if (!is_object($value) && !is_array($value)) {
return self::addIndentation(self::printableValue($value), $indent, 1);
}
$ret = '';
if (is_object($value)) {
$ret = get_class($value)."\nwith members ";
$value = array_filter(@(array)$value);
// Remove null characters that magically appear around keys for
// member variables of parent classes.
$transformed = array();
foreach ($value as $key => $x) {
$transformed[str_replace("\0", ' ', $key)] = $x;
}
$value = $transformed;
}
if ($max_members !== null) {
$value = array_slice($value, 0, $max_members, $preserve_keys = true);
}
$shallow = array();
if ($depth < $max_depth) {
foreach ($value as $k => $v) {
$shallow[$k] = self::printShallowRecursive(
$v,
$max_depth,
$max_members,
$depth + 1,
' ');
}
} else {
foreach ($value as $k => $v) {
// Extra indentation is for empty arrays, because they wrap on multiple
// lines and lookup stupid without the extra indentation
$shallow[$k] = self::addIndentation(self::printShort($v), $indent, 1);
}
}
return self::addIndentation($ret.print_r($shallow, true), $indent, 1);
}
/**
* Adds indentation to the beginning of every line starting from
* `$first_line`.
*
- * @param string Printed value.
- * @param string String to indent with.
- * @param int Index of first line to indent.
+ * @param string $value Printed value.
+ * @param string $indent String to indent with.
+ * @param int $first_line Index of first line to indent.
* @return string
* @task internal
*/
private static function addIndentation($value, $indent, $first_line) {
$lines = explode("\n", $value);
$out = array();
foreach ($lines as $index => $line) {
$out[] = $index >= $first_line ? $indent.$line : $line;
}
return implode("\n", $out);
}
}
diff --git a/src/repository/api/ArcanistGitAPI.php b/src/repository/api/ArcanistGitAPI.php
index 1ff299ec..aa5027ce 100644
--- a/src/repository/api/ArcanistGitAPI.php
+++ b/src/repository/api/ArcanistGitAPI.php
@@ -1,1825 +1,1825 @@
<?php
/**
* Interfaces with Git working copies.
*/
final class ArcanistGitAPI extends ArcanistRepositoryAPI {
private $repositoryHasNoCommits = false;
const SEARCH_LENGTH_FOR_PARENT_REVISIONS = 16;
/**
* For the repository's initial commit, 'git diff HEAD^' and similar do
* not work. Using this instead does work; it is the hash of the empty tree.
*/
const GIT_MAGIC_ROOT_COMMIT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
private $symbolicHeadCommit;
private $resolvedHeadCommit;
protected function buildLocalFuture(array $argv) {
$argv[0] = 'git '.$argv[0];
return newv('ExecFuture', $argv)
->setCWD($this->getPath());
}
public function newPassthru($pattern /* , ... */) {
$args = func_get_args();
static $git = null;
if ($git === null) {
if (phutil_is_windows()) {
// NOTE: On Windows, phutil_passthru() uses 'bypass_shell' because
// everything goes to hell if we don't. We must provide an absolute
// path to Git for this to work properly.
$git = Filesystem::resolveBinary('git');
$git = csprintf('%s', $git);
} else {
$git = 'git';
}
}
$args[0] = $git.' '.$args[0];
return newv('PhutilExecPassthru', $args)
->setCWD($this->getPath());
}
public function getSourceControlSystemName() {
return 'git';
}
public function getGitVersion() {
static $version = null;
if ($version === null) {
list($stdout) = $this->execxLocal('--version');
$version = rtrim(str_replace('git version ', '', $stdout));
}
return $version;
}
public function getMetadataPath() {
static $path = null;
if ($path === null) {
list($stdout) = $this->execxLocal('rev-parse --git-dir');
$path = rtrim($stdout, "\n");
// the output of git rev-parse --git-dir is an absolute path, unless
// the cwd is the root of the repository, in which case it uses the
// relative path of .git. If we get this relative path, turn it into
// an absolute path.
if ($path === '.git') {
$path = $this->getPath('.git');
}
}
return $path;
}
public function getHasCommits() {
return !$this->repositoryHasNoCommits;
}
/**
* Tests if a child commit is descendant of a parent commit.
* If child and parent are the same, it returns false.
- * @param Child commit SHA.
- * @param Parent commit SHA.
+ * @param $child Child commit SHA.
+ * @param $parent Parent commit SHA.
* @return bool True if the child is a descendant of the parent.
*/
private function isDescendant($child, $parent) {
list($common_ancestor) = $this->execxLocal(
'merge-base -- %s %s',
$child,
$parent);
$common_ancestor = trim($common_ancestor);
return ($common_ancestor == $parent) && ($common_ancestor != $child);
}
public function getLocalCommitInformation() {
if ($this->repositoryHasNoCommits) {
// Zero commits.
throw new Exception(
pht(
"You can't get local commit information for a repository with no ".
"commits."));
} else if ($this->getBaseCommit() == self::GIT_MAGIC_ROOT_COMMIT) {
// One commit.
$against = 'HEAD';
} else {
// 2..N commits. We include commits reachable from HEAD which are
// not reachable from the base commit; this is consistent with user
// expectations even though it is not actually the diff range.
// Particularly:
//
// |
// D <----- master branch
// |
// C Y <- feature branch
// | /|
// B X
// | /
// A
// |
//
// If "A, B, C, D" are master, and the user is at Y, when they run
// "arc diff B" they want (and get) a diff of B vs Y, but they think about
// this as being the commits X and Y. If we log "B..Y", we only show
// Y. With "Y --not B", we show X and Y.
if ($this->symbolicHeadCommit !== null) {
$base_commit = $this->getBaseCommit();
$resolved_base = $this->resolveCommit($base_commit);
$head_commit = $this->symbolicHeadCommit;
$resolved_head = $this->getHeadCommit();
if (!$this->isDescendant($resolved_head, $resolved_base)) {
// NOTE: Since the base commit will have been resolved as the
// merge-base of the specified base and the specified HEAD, we can't
// easily tell exactly what's wrong with the range.
// For example, `arc diff HEAD --head HEAD^^^` is invalid because it
// is reversed, but resolving the commit "HEAD" will compute its
// merge-base with "HEAD^^^", which is "HEAD^^^", so the range will
// appear empty.
throw new ArcanistUsageException(
pht(
'The specified commit range is empty, backward or invalid: the '.
'base (%s) is not an ancestor of the head (%s). You can not '.
'diff an empty or reversed commit range.',
$base_commit,
$head_commit));
}
}
$against = csprintf(
'%s --not %s',
$this->getHeadCommit(),
$this->getBaseCommit());
}
// NOTE: Windows escaping of "%" symbols apparently is inherently broken;
// when passed through escapeshellarg() they are replaced with spaces.
// TODO: Learn how cmd.exe works and find some clever workaround?
// NOTE: If we use "%x00", output is truncated in Windows.
list($info) = $this->execxLocal(
phutil_is_windows()
? 'log %C --format=%C --'
: 'log %C --format=%s --',
$against,
// NOTE: "%B" is somewhat new, use "%s%n%n%b" instead.
'%H%x01%T%x01%P%x01%at%x01%an%x01%aE%x01%s%x01%s%n%n%b%x02');
$commits = array();
$info = trim($info, " \n\2");
if (!strlen($info)) {
return array();
}
$info = explode("\2", $info);
foreach ($info as $line) {
list($commit, $tree, $parents, $time, $author, $author_email,
$title, $message) = explode("\1", trim($line), 8);
$message = rtrim($message);
$commits[$commit] = array(
'commit' => $commit,
'tree' => $tree,
'parents' => array_filter(explode(' ', $parents)),
'time' => $time,
'author' => $author,
'summary' => $title,
'message' => $message,
'authorEmail' => $author_email,
);
}
return $commits;
}
protected function buildBaseCommit($symbolic_commit) {
if ($symbolic_commit !== null) {
if ($symbolic_commit == self::GIT_MAGIC_ROOT_COMMIT) {
$this->setBaseCommitExplanation(
pht('you explicitly specified the empty tree.'));
return $symbolic_commit;
}
list($err, $merge_base) = $this->execManualLocal(
'merge-base -- %s %s',
$symbolic_commit,
$this->getHeadCommit());
if ($err) {
throw new ArcanistUsageException(
pht(
"Unable to find any git commit named '%s' in this repository.",
$symbolic_commit));
}
if ($this->symbolicHeadCommit === null) {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of the explicitly specified base commit ".
"'%s' and HEAD.",
$symbolic_commit));
} else {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of the explicitly specified base commit ".
"'%s' and the explicitly specified head commit '%s'.",
$symbolic_commit,
$this->symbolicHeadCommit));
}
return trim($merge_base);
}
// Detect zero-commit or one-commit repositories. There is only one
// relative-commit value that makes any sense in these repositories: the
// empty tree.
list($err) = $this->execManualLocal('rev-parse --verify HEAD^');
if ($err) {
list($err) = $this->execManualLocal('rev-parse --verify HEAD');
if ($err) {
$this->repositoryHasNoCommits = true;
}
if ($this->repositoryHasNoCommits) {
$this->setBaseCommitExplanation(pht('the repository has no commits.'));
} else {
$this->setBaseCommitExplanation(
pht('the repository has only one commit.'));
}
return self::GIT_MAGIC_ROOT_COMMIT;
}
if ($this->getBaseCommitArgumentRules() ||
$this->getConfigurationManager()->getConfigFromAnySource('base')) {
$base = $this->resolveBaseCommit();
if (!$base) {
throw new ArcanistUsageException(
pht(
"None of the rules in your 'base' configuration matched a valid ".
"commit. Adjust rules or specify which commit you want to use ".
"explicitly."));
}
return $base;
}
$do_write = false;
$default_relative = null;
$working_copy = $this->getWorkingCopyIdentity();
if ($working_copy) {
$default_relative = $working_copy->getProjectConfig(
'git.default-relative-commit');
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as specified in '%s' in ".
"'%s'. This setting overrides other settings.",
$default_relative,
'git.default-relative-commit',
'.arcconfig'));
}
if (!$default_relative) {
list($err, $upstream) = $this->execManualLocal(
'rev-parse --abbrev-ref --symbolic-full-name %s',
'@{upstream}');
if (!$err) {
$default_relative = trim($upstream);
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' (the Git upstream ".
"of the current branch) HEAD.",
$default_relative));
}
}
if (!$default_relative) {
$default_relative = $this->readScratchFile('default-relative-commit');
$default_relative = trim($default_relative);
if ($default_relative) {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as specified in '%s'.",
$default_relative,
'.git/arc/default-relative-commit'));
}
}
if (!$default_relative) {
// TODO: Remove the history lesson soon.
echo phutil_console_format(
"<bg:green>** %s **</bg>\n\n",
pht('Select a Default Commit Range'));
echo phutil_console_wrap(
pht(
"You're running a command which operates on a range of revisions ".
"(usually, from some revision to HEAD) but have not specified the ".
"revision that should determine the start of the range.\n\n".
"Previously, arc assumed you meant '%s' when you did not specify ".
"a start revision, but this behavior does not make much sense in ".
"most workflows outside of Facebook's historic %s workflow.\n\n".
"arc no longer assumes '%s'. You must specify a relative commit ".
"explicitly when you invoke a command (e.g., `%s`, not just `%s`) ".
"or select a default for this working copy.\n\nIn most cases, the ".
"best default is '%s'. You can also select '%s' to preserve the ".
"old behavior, or some other remote or branch. But you almost ".
"certainly want to select 'origin/master'.\n\n".
"(Technically: the merge-base of the selected revision and HEAD is ".
"used to determine the start of the commit range.)",
'HEAD^',
'git-svn',
'HEAD^',
'arc diff HEAD^',
'arc diff',
'origin/master',
'HEAD^'));
$prompt = pht('What default do you want to use? [origin/master]');
$default = phutil_console_prompt($prompt);
if (!strlen(trim($default))) {
$default = 'origin/master';
}
$default_relative = $default;
$do_write = true;
}
list($object_type) = $this->execxLocal(
'cat-file -t %s',
$default_relative);
if (trim($object_type) !== 'commit') {
throw new Exception(
pht(
"Relative commit '%s' is not the name of a commit!",
$default_relative));
}
if ($do_write) {
// Don't perform this write until we've verified that the object is a
// valid commit name.
$this->writeScratchFile('default-relative-commit', $default_relative);
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as you just specified.",
$default_relative));
}
list($merge_base) = $this->execxLocal(
'merge-base -- %s HEAD',
$default_relative);
return trim($merge_base);
}
public function getHeadCommit() {
if ($this->resolvedHeadCommit === null) {
$this->resolvedHeadCommit = $this->resolveCommit(
coalesce($this->symbolicHeadCommit, 'HEAD'));
}
return $this->resolvedHeadCommit;
}
public function setHeadCommit($symbolic_commit) {
$this->symbolicHeadCommit = $symbolic_commit;
$this->reloadCommitRange();
return $this;
}
/**
* Translates a symbolic commit (like "HEAD^") to a commit identifier.
- * @param string_symbol commit.
+ * @param string_symbol $symbolic_commit commit.
* @return string the commit SHA.
*/
private function resolveCommit($symbolic_commit) {
list($err, $commit_hash) = $this->execManualLocal(
'rev-parse %s',
$symbolic_commit);
if ($err) {
throw new ArcanistUsageException(
pht(
"Unable to find any git commit named '%s' in this repository.",
$symbolic_commit));
}
return trim($commit_hash);
}
private function getDiffFullOptions($detect_moves_and_renames = true) {
$options = array(
self::getDiffBaseOptions(),
'--no-color',
'--src-prefix=a/',
'--dst-prefix=b/',
'-U'.$this->getDiffLinesOfContext(),
);
if ($detect_moves_and_renames) {
$options[] = '-M';
$options[] = '-C';
}
return implode(' ', $options);
}
private function getDiffBaseOptions() {
$options = array(
// Disable external diff drivers, like graphical differs, since Arcanist
// needs to capture the diff text.
'--no-ext-diff',
// Disable textconv so we treat binary files as binary, even if they have
// an alternative textual representation. TODO: Ideally, Differential
// would ship up the binaries for 'arc patch' but display the textconv
// output in the visual diff.
'--no-textconv',
// Provide a standard view of submodule changes; the 'log' and 'diff'
// values do not parse by the diff parser.
'--submodule=short',
);
return implode(' ', $options);
}
/**
- * @param the base revision
- * @param head revision. If this is null, the generated diff will include the
- * working copy
+ * @param $base the base revision
+ * @param $head (optional) head revision. If this is null, the generated diff
+ * will include the working copy
*/
public function getFullGitDiff($base, $head = null) {
$options = $this->getDiffFullOptions();
$config_options = array();
// See T13432. Disable the rare "diff.suppressBlankEmpty" configuration
// option, which discards the " " (space) change type prefix on unchanged
// blank lines. At time of writing the parser does not handle these
// properly, but generating a more-standard diff is generally desirable
// even if a future parser handles this case more gracefully.
$config_options[] = '-c';
$config_options[] = 'diff.suppressBlankEmpty=false';
if ($head !== null) {
list($stdout) = $this->execxLocal(
"%LR diff {$options} %s %s --",
$config_options,
$base,
$head);
} else {
list($stdout) = $this->execxLocal(
"%LR diff {$options} %s --",
$config_options,
$base);
}
return $stdout;
}
/**
- * @param string Path to generate a diff for.
- * @param bool If true, detect moves and renames. Otherwise, ignore
- * moves/renames; this is useful because it prompts git to
- * generate real diff text.
+ * @param string $path Path to generate a diff for.
+ * @param bool $detect_moves_and_renames (optional) If true, detect moves
+ * and renames. Otherwise, ignore moves/renames; this is useful
+ * because it prompts git to generate real diff text.
*/
public function getRawDiffText($path, $detect_moves_and_renames = true) {
$options = $this->getDiffFullOptions($detect_moves_and_renames);
list($stdout) = $this->execxLocal(
"diff {$options} %s -- %s",
$this->getBaseCommit(),
$path);
return $stdout;
}
private function getBranchNameFromRef($ref) {
$count = 0;
$branch = preg_replace('/^refs\/heads\//', '', $ref, 1, $count);
if ($count !== 1) {
return null;
}
if (!strlen($branch)) {
return null;
}
return $branch;
}
public function getBranchName() {
list($err, $stdout, $stderr) = $this->execManualLocal(
'symbolic-ref --quiet HEAD');
if ($err === 0) {
// We expect the branch name to come qualified with a refs/heads/ prefix.
// Verify this, and strip it.
$ref = rtrim($stdout);
$branch = $this->getBranchNameFromRef($ref);
if ($branch === null) {
throw new Exception(
pht('Failed to parse %s output!', 'git symbolic-ref'));
}
return $branch;
} else if ($err === 1) {
// Exit status 1 with --quiet indicates that HEAD is detached.
return null;
} else {
throw new Exception(
pht('Command %s failed: %s', 'git symbolic-ref', $stderr));
}
}
public function getRemoteURI() {
// Determine which remote to examine; default to 'origin'
$remote = 'origin';
$branch = $this->getBranchName();
if ($branch) {
$path = $this->getPathToUpstream($branch);
if ($path->isConnectedToRemote()) {
$remote = $path->getRemoteRemoteName();
}
}
return $this->getGitRemoteFetchURI($remote);
}
public function getSourceControlPath() {
// TODO: Try to get something useful here.
return null;
}
public function getGitCommitLog() {
$relative = $this->getBaseCommit();
if ($this->repositoryHasNoCommits) {
// No commits yet.
return '';
} else if ($relative == self::GIT_MAGIC_ROOT_COMMIT) {
// First commit.
list($stdout) = $this->execxLocal(
'log --format=medium HEAD --');
} else {
// 2..N commits.
list($stdout) = $this->execxLocal(
'log --first-parent --format=medium %s --',
gitsprintf(
'%s..%s',
$this->getBaseCommit(),
$this->getHeadCommit()));
}
return $stdout;
}
public function getGitHistoryLog() {
list($stdout) = $this->execxLocal(
'log --format=medium -n%d %s --',
self::SEARCH_LENGTH_FOR_PARENT_REVISIONS,
gitsprintf('%s', $this->getBaseCommit()));
return $stdout;
}
public function getSourceControlBaseRevision() {
list($stdout) = $this->execxLocal(
'rev-parse %s',
$this->getBaseCommit());
return rtrim($stdout, "\n");
}
public function getCanonicalRevisionName($string) {
$match = null;
if ($string !== null && preg_match('/@([0-9]+)$/', $string, $match)) {
$stdout = $this->getHashFromFromSVNRevisionNumber($match[1]);
} else {
list($stdout) = $this->execxLocal(
'show -s --format=%s %s --',
'%H',
$string);
}
return rtrim($stdout);
}
private function executeSVNFindRev($input, $vcs) {
$match = array();
list($stdout) = $this->execxLocal(
'svn find-rev %s',
$input);
if (!$stdout) {
throw new ArcanistUsageException(
pht(
'Cannot find the %s equivalent of %s.',
$vcs,
$input));
}
// When git performs a partial-rebuild during svn
// look-up, we need to parse the final line
$lines = explode("\n", $stdout);
$stdout = $lines[count($lines) - 2];
return rtrim($stdout);
}
// Convert svn revision number to git hash
public function getHashFromFromSVNRevisionNumber($revision_id) {
return $this->executeSVNFindRev('r'.$revision_id, 'Git');
}
// Convert a git hash to svn revision number
public function getSVNRevisionNumberFromHash($hash) {
return $this->executeSVNFindRev($hash, 'SVN');
}
private function buildUncommittedStatusViaStatus() {
$status = $this->buildLocalFuture(
array(
'status --porcelain=2 -z',
));
list($stdout) = $status->resolvex();
$result = new PhutilArrayWithDefaultValue();
$parts = explode("\0", $stdout);
while (count($parts) > 1) {
$entry = array_shift($parts);
$entry_parts = explode(' ', $entry, 2);
if ($entry_parts[0] == '1') {
$entry_parts = explode(' ', $entry, 9);
$path = $entry_parts[8];
} else if ($entry_parts[0] == '2') {
$entry_parts = explode(' ', $entry, 10);
$path = $entry_parts[9];
} else if ($entry_parts[0] == 'u') {
$entry_parts = explode(' ', $entry, 11);
$path = $entry_parts[10];
} else if ($entry_parts[0] == '?') {
$entry_parts = explode(' ', $entry, 2);
$result[$entry_parts[1]] = self::FLAG_UNTRACKED;
continue;
}
$result[$path] |= self::FLAG_UNCOMMITTED;
$index_state = substr($entry_parts[1], 0, 1);
$working_state = substr($entry_parts[1], 1, 1);
if ($index_state == 'A') {
$result[$path] |= self::FLAG_ADDED;
} else if ($index_state == 'M') {
$result[$path] |= self::FLAG_MODIFIED;
} else if ($index_state == 'D') {
$result[$path] |= self::FLAG_DELETED;
}
if ($working_state != '.') {
$result[$path] |= self::FLAG_UNSTAGED;
if ($index_state == '.') {
if ($working_state == 'A') {
$result[$path] |= self::FLAG_ADDED;
} else if ($working_state == 'M') {
$result[$path] |= self::FLAG_MODIFIED;
} else if ($working_state == 'D') {
$result[$path] |= self::FLAG_DELETED;
}
}
}
$submodule_tracked = substr($entry_parts[2], 2, 1);
$submodule_untracked = substr($entry_parts[2], 3, 1);
if ($submodule_tracked == 'M' || $submodule_untracked == 'U') {
$result[$path] |= self::FLAG_EXTERNALS;
}
if ($entry_parts[0] == '2') {
$result[array_shift($parts)] = $result[$path] | self::FLAG_DELETED;
$result[$path] |= self::FLAG_ADDED;
}
}
return $result->toArray();
}
protected function buildUncommittedStatus() {
if (version_compare($this->getGitVersion(), '2.11.0', '>=')) {
return $this->buildUncommittedStatusViaStatus();
}
$diff_options = $this->getDiffBaseOptions();
if ($this->repositoryHasNoCommits) {
$diff_base = self::GIT_MAGIC_ROOT_COMMIT;
} else {
$diff_base = 'HEAD';
}
// Find uncommitted changes.
$uncommitted_future = $this->buildLocalFuture(
array(
'diff %C --raw %s --',
$diff_options,
gitsprintf('%s', $diff_base),
));
$untracked_future = $this->buildLocalFuture(
array(
'ls-files --others --exclude-standard',
));
// Unstaged changes
$unstaged_future = $this->buildLocalFuture(
array(
'diff-files --name-only',
));
$futures = array(
$uncommitted_future,
$untracked_future,
// NOTE: `git diff-files` races with each of these other commands
// internally, and resolves with inconsistent results if executed
// in parallel. To work around this, DO NOT run it at the same time.
// After the other commands exit, we can start the `diff-files` command.
);
id(new FutureIterator($futures))->resolveAll();
// We're clear to start the `git diff-files` now.
$unstaged_future->start();
$result = new PhutilArrayWithDefaultValue();
list($stdout) = $uncommitted_future->resolvex();
$uncommitted_files = $this->parseGitRawDiff($stdout);
foreach ($uncommitted_files as $path => $mask) {
$result[$path] |= ($mask | self::FLAG_UNCOMMITTED);
}
list($stdout) = $untracked_future->resolvex();
$stdout = rtrim($stdout, "\n");
if (strlen($stdout)) {
$stdout = explode("\n", $stdout);
foreach ($stdout as $path) {
$result[$path] |= self::FLAG_UNTRACKED;
}
}
list($stdout, $stderr) = $unstaged_future->resolvex();
$stdout = rtrim($stdout, "\n");
if (strlen($stdout)) {
$stdout = explode("\n", $stdout);
foreach ($stdout as $path) {
$result[$path] |= self::FLAG_UNSTAGED;
}
}
return $result->toArray();
}
protected function buildCommitRangeStatus() {
list($stdout, $stderr) = $this->execxLocal(
'diff %C --raw %s HEAD --',
$this->getDiffBaseOptions(),
gitsprintf('%s', $this->getBaseCommit()));
return $this->parseGitRawDiff($stdout);
}
public function getGitConfig($key, $default = null) {
list($err, $stdout) = $this->execManualLocal('config %s', $key);
if ($err) {
return $default;
}
return rtrim($stdout);
}
public function getAuthor() {
list($stdout) = $this->execxLocal('var GIT_AUTHOR_IDENT');
return preg_replace('/\s+<.*/', '', rtrim($stdout, "\n"));
}
public function addToCommit(array $paths) {
$this->execxLocal(
'add -A -- %Ls',
$paths);
$this->reloadWorkingCopy();
return $this;
}
public function doCommit($message) {
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
// NOTE: "--allow-empty-message" was introduced some time after 1.7.0.4,
// so we do not provide it and thus require a message.
$this->execxLocal(
'commit -F %s',
$tmp_file);
$this->reloadWorkingCopy();
return $this;
}
public function amendCommit($message = null) {
if ($message === null) {
$this->execxLocal('commit --amend --allow-empty -C HEAD');
} else {
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
$this->execxLocal(
'commit --amend --allow-empty -F %s',
$tmp_file);
}
$this->reloadWorkingCopy();
return $this;
}
private function parseGitRawDiff($status, $full = false) {
static $flags = array(
'A' => self::FLAG_ADDED,
'M' => self::FLAG_MODIFIED,
'D' => self::FLAG_DELETED,
);
$status = trim($status);
$lines = array();
foreach (explode("\n", $status) as $line) {
if ($line) {
$lines[] = preg_split("/[ \t]/", $line, 6);
}
}
$files = array();
foreach ($lines as $line) {
$mask = 0;
// "git diff --raw" lines begin with a ":" character.
$old_mode = ltrim($line[0], ':');
$new_mode = $line[1];
// The hashes may be padded with "." characters for alignment. Discard
// them.
$old_hash = rtrim($line[2], '.');
$new_hash = rtrim($line[3], '.');
$flag = $line[4];
$file = $line[5];
$new_value = intval($new_mode, 8);
$is_submodule = (($new_value & 0160000) === 0160000);
if (($is_submodule) &&
($flag == 'M') &&
($old_hash === $new_hash) &&
($old_mode === $new_mode)) {
// See T9455. We see this submodule as "modified", but the old and new
// hashes are the same and the old and new modes are the same, so we
// don't directly see a modification.
// We can end up here if we have a submodule which has uncommitted
// changes inside of it (for example, the user has added untracked
// files or made uncommitted changes to files in the submodule). In
// this case, we set a different flag because we can't meaningfully
// give users the same prompt.
// Note that if the submodule has real changes from the parent
// perspective (the base commit has changed) and also has uncommitted
// changes, we'll only see the real changes and miss the uncommitted
// changes. At the time of writing, there is no reasonable porcelain
// for finding those changes, and the impact of this error seems small.
$mask |= self::FLAG_EXTERNALS;
} else if (isset($flags[$flag])) {
$mask |= $flags[$flag];
} else if ($flag[0] == 'R') {
$both = explode("\t", $file);
if ($full) {
$files[$both[0]] = array(
'mask' => $mask | self::FLAG_DELETED,
'ref' => str_repeat('0', 40),
);
} else {
$files[$both[0]] = $mask | self::FLAG_DELETED;
}
$file = $both[1];
$mask |= self::FLAG_ADDED;
} else if ($flag[0] == 'C') {
$both = explode("\t", $file);
$file = $both[1];
$mask |= self::FLAG_ADDED;
}
if ($full) {
$files[$file] = array(
'mask' => $mask,
'ref' => $new_hash,
);
} else {
$files[$file] = $mask;
}
}
return $files;
}
public function getAllFiles() {
$future = $this->buildLocalFuture(array('ls-files -z'));
return id(new LinesOfALargeExecFuture($future))
->setDelimiter("\0");
}
public function getChangedFiles($since_commit) {
list($stdout) = $this->execxLocal(
'diff --raw %s --',
gitsprintf('%s', $since_commit));
return $this->parseGitRawDiff($stdout);
}
public function getBlame($path) {
list($stdout) = $this->execxLocal(
'blame --porcelain -w -M %s -- %s',
gitsprintf('%s', $this->getBaseCommit()),
$path);
// the --porcelain format prints at least one header line per source line,
// then the source line prefixed by a tab character
$blame_info = preg_split('/^\t.*\n/m', rtrim($stdout));
// commit info is not repeated in these headers, so cache it
$revision_data = array();
$blame = array();
foreach ($blame_info as $line_info) {
$revision = substr($line_info, 0, 40);
$data = idx($revision_data, $revision, array());
if (empty($data)) {
$matches = array();
if (!preg_match('/^author (.*)$/m', $line_info, $matches)) {
throw new Exception(
pht(
'Unexpected output from %s: no author for commit %s',
'git blame',
$revision));
}
$data['author'] = $matches[1];
$data['from_first_commit'] = preg_match('/^boundary$/m', $line_info);
$revision_data[$revision] = $data;
}
// Ignore lines predating the git repository (on a boundary commit)
// rather than blaming them on the oldest diff's unfortunate author
if (!$data['from_first_commit']) {
$blame[] = array($data['author'], $revision);
}
}
return $blame;
}
public function getOriginalFileData($path) {
return $this->getFileDataAtRevision($path, $this->getBaseCommit());
}
public function getCurrentFileData($path) {
return $this->getFileDataAtRevision($path, 'HEAD');
}
private function parseGitTree($stdout) {
$result = array();
$stdout = trim($stdout);
if (!strlen($stdout)) {
return $result;
}
$lines = explode("\n", $stdout);
foreach ($lines as $line) {
$matches = array();
$ok = preg_match(
'/^(\d{6}) (blob|tree|commit) ([a-z0-9]{40})[\t](.*)$/',
$line,
$matches);
if (!$ok) {
throw new Exception(pht('Failed to parse %s output!', 'git ls-tree'));
}
$result[$matches[4]] = array(
'mode' => $matches[1],
'type' => $matches[2],
'ref' => $matches[3],
);
}
return $result;
}
private function getFileDataAtRevision($path, $revision) {
// NOTE: We don't want to just "git show {$revision}:{$path}" since if the
// path was a directory at the given revision we'll get a list of its files
// and treat it as though it as a file containing a list of other files,
// which is silly.
if (!strlen($path)) {
// No filename, so there's no content (Probably new/deleted file).
return null;
}
list($stdout) = $this->execxLocal(
'ls-tree %s -- %s',
gitsprintf('%s', $revision),
$path);
$info = $this->parseGitTree($stdout);
if (empty($info[$path])) {
// No such path, or the path is a directory and we executed 'ls-tree dir/'
// and got a list of its contents back.
return null;
}
if ($info[$path]['type'] != 'blob') {
// Path is or was a directory, not a file.
return null;
}
list($stdout) = $this->execxLocal(
'cat-file blob -- %s',
$info[$path]['ref']);
return $stdout;
}
/**
* Returns names of all the branches in the current repository.
*
* @return list<dict<string, string>> Dictionary of branch information.
*/
private function getAllBranches() {
$field_list = array(
'%(refname)',
'%(objectname)',
'%(committerdate:raw)',
'%(tree)',
'%(subject)',
'%(subject)%0a%0a%(body)',
'%02',
);
list($stdout) = $this->execxLocal(
'for-each-ref --format=%s -- refs/heads',
implode('%01', $field_list));
$current = $this->getBranchName();
$result = array();
$lines = explode("\2", $stdout);
foreach ($lines as $line) {
$line = trim($line);
if (!strlen($line)) {
continue;
}
$fields = explode("\1", $line, 6);
list($ref, $hash, $epoch, $tree, $desc, $text) = $fields;
$branch = $this->getBranchNameFromRef($ref);
if ($branch !== null) {
$result[] = array(
'current' => ($branch === $current),
'name' => $branch,
'ref' => $ref,
'hash' => $hash,
'tree' => $tree,
'epoch' => (int)$epoch,
'desc' => $desc,
'text' => $text,
);
}
}
return $result;
}
public function getBaseCommitRef() {
$base_commit = $this->getBaseCommit();
if ($base_commit === self::GIT_MAGIC_ROOT_COMMIT) {
return null;
}
$base_message = $this->getCommitMessage($base_commit);
// TODO: We should also pull the tree hash.
return $this->newCommitRef()
->setCommitHash($base_commit)
->attachMessage($base_message);
}
public function getWorkingCopyRevision() {
list($stdout) = $this->execxLocal('rev-parse HEAD');
return rtrim($stdout, "\n");
}
public function isHistoryDefaultImmutable() {
return false;
}
public function supportsAmend() {
return true;
}
public function supportsCommitRanges() {
return true;
}
public function supportsLocalCommits() {
return true;
}
public function hasLocalCommit($commit) {
try {
if (!$this->getCanonicalRevisionName($commit)) {
return false;
}
} catch (CommandException $exception) {
return false;
}
return true;
}
public function getAllLocalChanges() {
$diff = $this->getFullGitDiff($this->getBaseCommit());
if (!strlen(trim($diff))) {
return array();
}
$parser = new ArcanistDiffParser();
return $parser->parseDiff($diff);
}
public function getFinalizedRevisionMessage() {
return pht(
"You may now push this commit upstream, as appropriate (e.g. with ".
"'%s', or '%s', or by printing and faxing it).",
'git push',
'git svn dcommit');
}
public function getCommitMessage($commit) {
list($message) = $this->execxLocal(
'log -n1 --format=%C %s --',
'%s%n%n%b',
gitsprintf('%s', $commit));
return $message;
}
public function loadWorkingCopyDifferentialRevisions(
ConduitClient $conduit,
array $query) {
$messages = $this->getGitCommitLog();
if (!strlen($messages)) {
return array();
}
$parser = new ArcanistDiffParser();
$messages = $parser->parseDiff($messages);
// First, try to find revisions by explicit revision IDs in commit messages.
$reason_map = array();
$revision_ids = array();
foreach ($messages as $message) {
$object = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$message->getMetadata('message'));
if ($object->getRevisionID()) {
$revision_ids[] = $object->getRevisionID();
$reason_map[$object->getRevisionID()] = $message->getCommitHash();
}
}
if ($revision_ids) {
$results = $conduit->callMethodSynchronous(
'differential.query',
$query + array(
'ids' => $revision_ids,
));
foreach ($results as $key => $result) {
$hash = substr($reason_map[$result['id']], 0, 16);
$results[$key]['why'] = pht(
"Commit message for '%s' has explicit 'Differential Revision'.",
$hash);
}
return $results;
}
// If we didn't succeed, try to find revisions by hash.
$hashes = array();
foreach ($this->getLocalCommitInformation() as $commit) {
$hashes[] = array('gtcm', $commit['commit']);
$hashes[] = array('gttr', $commit['tree']);
}
$results = $conduit->callMethodSynchronous(
'differential.query',
$query + array(
'commitHashes' => $hashes,
));
foreach ($results as $key => $result) {
$results[$key]['why'] = pht(
'A git commit or tree hash in the commit range is already attached '.
'to the Differential revision.');
}
return $results;
}
public function updateWorkingCopy() {
$this->execxLocal('pull');
$this->reloadWorkingCopy();
}
public function getCommitSummary($commit) {
if ($commit == self::GIT_MAGIC_ROOT_COMMIT) {
return pht('(The Empty Tree)');
}
list($summary) = $this->execxLocal(
'log -n 1 %s %s --',
'--format=%s',
gitsprintf('%s', $commit));
return trim($summary);
}
public function isGitSubversionRepo() {
return Filesystem::pathExists($this->getPath('.git/svn'));
}
public function resolveBaseCommitRule($rule, $source) {
list($type, $name) = explode(':', $rule, 2);
switch ($type) {
case 'git':
$matches = null;
if (preg_match('/^merge-base\((.+)\)$/', $name, $matches)) {
list($err, $merge_base) = $this->execManualLocal(
'merge-base -- %s HEAD',
$matches[1]);
if (!$err) {
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of '%s' and HEAD, as specified by ".
"'%s' in your %s 'base' configuration.",
$matches[1],
$rule,
$source));
return trim($merge_base);
}
} else if (preg_match('/^branch-unique\((.+)\)$/', $name, $matches)) {
list($err, $merge_base) = $this->execManualLocal(
'merge-base -- %s HEAD',
$matches[1]);
if ($err) {
return null;
}
$merge_base = trim($merge_base);
list($commits) = $this->execxLocal(
'log --format=%C %s..HEAD --',
'%H',
$merge_base);
$commits = array_filter(explode("\n", $commits));
if (!$commits) {
return null;
}
$commits[] = $merge_base;
$head_branch_count = null;
$all_branch_names = ipull($this->getAllBranches(), 'name');
foreach ($commits as $commit) {
// Ideally, we would use something like "for-each-ref --contains"
// to get a filtered list of branches ready for script consumption.
// Instead, try to get predictable output from "branch --contains".
$flags = array();
$flags[] = '--no-color';
// NOTE: The "--no-column" flag was introduced in Git 1.7.11, so
// don't pass it if we're running an older version. See T9953.
$version = $this->getGitVersion();
if (version_compare($version, '1.7.11', '>=')) {
$flags[] = '--no-column';
}
list($branches) = $this->execxLocal(
'branch %Ls --contains %s',
$flags,
$commit);
$branches = array_filter(explode("\n", $branches));
// Filter the list, removing the "current" marker (*) and ignoring
// anything other than known branch names (mainly, any possible
// "detached HEAD" or "no branch" line).
foreach ($branches as $key => $branch) {
$branch = trim($branch, ' *');
if (in_array($branch, $all_branch_names)) {
$branches[$key] = $branch;
} else {
unset($branches[$key]);
}
}
if ($head_branch_count === null) {
// If this is the first commit, it's HEAD. Count how many
// branches it is on; we want to include commits on the same
// number of branches. This covers a case where this branch
// has sub-branches and we're running "arc diff" here again
// for whatever reason.
$head_branch_count = count($branches);
} else if (count($branches) > $head_branch_count) {
$branches = implode(', ', $branches);
$this->setBaseCommitExplanation(
pht(
"it is the first commit between '%s' (the merge-base of ".
"'%s' and HEAD) which is also contained by another branch ".
"(%s).",
$merge_base,
$matches[1],
$branches));
return $commit;
}
}
} else {
list($err) = $this->execManualLocal(
'cat-file -t %s',
$name);
if (!$err) {
$this->setBaseCommitExplanation(
pht(
"it is specified by '%s' in your %s 'base' configuration.",
$rule,
$source));
return $name;
}
}
break;
case 'arc':
switch ($name) {
case 'empty':
$this->setBaseCommitExplanation(
pht(
"you specified '%s' in your %s 'base' configuration.",
$rule,
$source));
return self::GIT_MAGIC_ROOT_COMMIT;
case 'amended':
$text = $this->getCommitMessage('HEAD');
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$text);
if ($message->getRevisionID()) {
$this->setBaseCommitExplanation(
pht(
"HEAD has been amended with 'Differential Revision:', ".
"as specified by '%s' in your %s 'base' configuration.",
$rule,
$source));
return 'HEAD^';
}
break;
case 'upstream':
list($err, $upstream) = $this->execManualLocal(
'rev-parse --abbrev-ref --symbolic-full-name %s',
'@{upstream}');
if (!$err) {
$upstream = rtrim($upstream);
list($upstream_merge_base) = $this->execxLocal(
'merge-base -- %s HEAD',
$upstream);
$upstream_merge_base = rtrim($upstream_merge_base);
$this->setBaseCommitExplanation(
pht(
"it is the merge-base of the upstream of the current branch ".
"and HEAD, and matched the rule '%s' in your %s ".
"'base' configuration.",
$rule,
$source));
return $upstream_merge_base;
}
break;
case 'this':
$this->setBaseCommitExplanation(
pht(
"you specified '%s' in your %s 'base' configuration.",
$rule,
$source));
return 'HEAD^';
}
default:
return null;
}
return null;
}
public function canStashChanges() {
return true;
}
public function stashChanges() {
$this->execxLocal('stash');
$this->reloadWorkingCopy();
}
public function unstashChanges() {
$this->execxLocal('stash pop');
}
protected function didReloadCommitRange() {
// After an amend, the symbolic head may resolve to a different commit.
$this->resolvedHeadCommit = null;
}
/**
* Follow the chain of tracking branches upstream until we reach a remote
* or cycle locally.
*
- * @param string Ref to start from.
+ * @param string $start Ref to start from.
* @return ArcanistGitUpstreamPath Path to an upstream.
*/
public function getPathToUpstream($start) {
$cursor = $start;
$path = new ArcanistGitUpstreamPath();
while (true) {
list($err, $upstream) = $this->execManualLocal(
'rev-parse --symbolic-full-name %s@{upstream}',
$cursor);
if ($err) {
// We ended up somewhere with no tracking branch, so we're done.
break;
}
$upstream = trim($upstream);
if (preg_match('(^refs/heads/)', $upstream)) {
$upstream = preg_replace('(^refs/heads/)', '', $upstream);
$is_cycle = $path->getUpstream($upstream);
$path->addUpstream(
$cursor,
array(
'type' => ArcanistGitUpstreamPath::TYPE_LOCAL,
'name' => $upstream,
'cycle' => $is_cycle,
));
if ($is_cycle) {
// We ran into a local cycle, so we're done.
break;
}
// We found another local branch, so follow that one upriver.
$cursor = $upstream;
continue;
}
if (preg_match('(^refs/remotes/)', $upstream)) {
$upstream = preg_replace('(^refs/remotes/)', '', $upstream);
list($remote, $branch) = explode('/', $upstream, 2);
$path->addUpstream(
$cursor,
array(
'type' => ArcanistGitUpstreamPath::TYPE_REMOTE,
'name' => $branch,
'remote' => $remote,
));
// We found a remote, so we're done.
break;
}
throw new Exception(
pht(
'Got unrecognized upstream format ("%s") from Git, expected '.
'"refs/heads/..." or "refs/remotes/...".',
$upstream));
}
return $path;
}
public function isPerforceRemote($remote_name) {
// See T13434. In Perforce workflows, "git p4 clone" creates "p4" refs
// under "refs/remotes/", but does not define a real remote named "p4".
// We treat this remote as though it were a real remote during "arc land",
// but it does not respond to commands like "git remote show p4", so we
// need to handle it specially.
if ($remote_name !== 'p4') {
return false;
}
$remote_dir = $this->getMetadataPath().'/refs/remotes/p4';
if (!Filesystem::pathExists($remote_dir)) {
return false;
}
return true;
}
public function isPushableRemote($remote_name) {
$uri = $this->getGitRemotePushURI($remote_name);
return ($uri !== null);
}
public function isFetchableRemote($remote_name) {
$uri = $this->getGitRemoteFetchURI($remote_name);
return ($uri !== null);
}
private function getGitRemoteFetchURI($remote_name) {
return $this->getGitRemoteURI($remote_name, $for_push = false);
}
private function getGitRemotePushURI($remote_name) {
return $this->getGitRemoteURI($remote_name, $for_push = true);
}
private function getGitRemoteURI($remote_name, $for_push) {
$remote_uri = $this->loadGitRemoteURI($remote_name, $for_push);
if ($remote_uri !== null) {
$remote_uri = rtrim($remote_uri);
if (!strlen($remote_uri)) {
$remote_uri = null;
}
}
return $remote_uri;
}
private function loadGitRemoteURI($remote_name, $for_push) {
// Try to identify the best URI for a given remote. This is complicated
// because remotes may have different "push" and "fetch" URIs, may
// rewrite URIs with "insteadOf" configuration, and different versions
// of Git support different URI resolution commands.
// Remotes may also have more than one URI of a given type, but we ignore
// those cases here.
// Start with "git remote get-url [--push]". This is the simplest and
// most accurate command, but was introduced most recently in Git's
// history.
$argv = array();
if ($for_push) {
$argv[] = '--push';
}
list($err, $stdout) = $this->execManualLocal(
'remote get-url %Ls -- %s',
$argv,
$remote_name);
if (!$err) {
return $stdout;
}
// See T13481. If "git remote get-url [--push]" failed, it might be because
// the remote does not exist, but it might also be because the version of
// Git is too old to support "git remote get-url", which was introduced
// in Git 2.7 (circa late 2015).
$git_version = $this->getGitVersion();
if (version_compare($git_version, '2.7', '>=')) {
// This version of Git should support "git remote get-url --push", but
// the command failed, so conclude this is not a valid remote and thus
// there is no remote URI.
return null;
}
// If we arrive here, we're in a version of Git which is too old to
// support "git remote get-url [--push]". We're going to fall back to
// older and less accurate mechanisms for figuring out the remote URI.
// The first mechanism we try is "git ls-remote --get-url". This exists
// in Git 1.7.5 or newer. It only gives us the fetch URI, so this result
// will be incorrect if a remote has different fetch and push URIs.
// However, this is very rare, and this result is almost always correct.
// Note that some old versions of Git do not parse "--" in this command
// properly. We omit it since it doesn't seem like there's anything
// dangerous an attacker can do even if they can choose a remote name to
// intentionally cause an argument misparse.
// This will cause the command to behave incorrectly for remotes with
// names which are also valid flags, like "--quiet".
list($err, $stdout) = $this->execManualLocal(
'ls-remote --get-url %s',
$remote_name);
if (!$err) {
// The "git ls-remote --get-url" command just echoes the remote name
// (like "origin") if no remote URI is found. Treat this like a failure.
$output_is_input = (rtrim($stdout) === $remote_name);
if (!$output_is_input) {
return $stdout;
}
}
if (version_compare($git_version, '1.7.5', '>=')) {
// This version of Git should support "git ls-remote --get-url", but
// the command failed (or echoed the input), so conclude the remote
// really does not exist.
return null;
}
// Fall back to the very old "git config -- remote.origin.url" command.
// This does not give us push URLs and does not resolve "insteadOf"
// aliases, but still works in the simplest (and most common) cases.
list($err, $stdout) = $this->execManualLocal(
'config -- %s',
sprintf('remote.%s.url', $remote_name));
if (!$err) {
return $stdout;
}
return null;
}
protected function newCurrentCommitSymbol() {
return 'HEAD';
}
public function isGitLFSWorkingCopy() {
// We're going to run:
//
// $ git ls-files -z -- ':(attr:filter=lfs)'
//
// ...and exit as soon as it generates any field terminated with a "\0".
//
// If this command generates any such output, that means this working copy
// contains at least one LFS file, so it's an LFS working copy. If it
// exits with no error and no output, this is not an LFS working copy.
//
// If it exits with an error, we're in trouble.
$future = $this->buildLocalFuture(
array(
'ls-files -z -- %s',
':(attr:filter=lfs)',
));
$lfs_list = id(new LinesOfALargeExecFuture($future))
->setDelimiter("\0");
try {
foreach ($lfs_list as $lfs_file) {
// We have our answer, so we can throw the subprocess away.
$future->resolveKill();
return true;
}
return false;
} catch (CommandException $ex) {
// This is probably an older version of Git. Continue below.
}
// In older versions of Git, the first command will fail with an error
// ("Invalid pathspec magic..."). See PHI1718.
//
// Some other tests we could use include:
//
// (1) Look for ".gitattributes" at the repository root. This approach is
// a rough approximation because ".gitattributes" may be global or in a
// subdirectory. See D21190.
//
// (2) Use "git check-attr" and pipe a bunch of files into it, roughly
// like this:
//
// $ git ls-files -z -- | git check-attr --stdin -z filter --
//
// However, the best version of this check I could come up with is fairly
// slow in even moderately large repositories (~200ms in a repository with
// 10K paths). See D21190.
//
// (3) Use "git lfs ls-files". This is even worse than piping "ls-files"
// to "check-attr" in PHP (~600ms in a repository with 10K paths).
//
// (4) Give up and just assume the repository isn't LFS. This is the
// current behavior.
return false;
}
protected function newLandEngine() {
return new ArcanistGitLandEngine();
}
protected function newWorkEngine() {
return new ArcanistGitWorkEngine();
}
public function newLocalState() {
return id(new ArcanistGitLocalState())
->setRepositoryAPI($this);
}
public function readRawCommit($hash) {
list($stdout) = $this->execxLocal(
'cat-file commit -- %s',
$hash);
return ArcanistGitRawCommit::newFromRawBlob($stdout);
}
public function writeRawCommit(ArcanistGitRawCommit $commit) {
$blob = $commit->getRawBlob();
$future = $this->execFutureLocal('hash-object -t commit --stdin -w');
$future->write($blob);
list($stdout) = $future->resolvex();
return trim($stdout);
}
protected function newSupportedMarkerTypes() {
return array(
ArcanistMarkerRef::TYPE_BRANCH,
);
}
protected function newMarkerRefQueryTemplate() {
return new ArcanistGitRepositoryMarkerQuery();
}
protected function newRemoteRefQueryTemplate() {
return new ArcanistGitRepositoryRemoteQuery();
}
protected function newNormalizedURI($uri) {
return new ArcanistRepositoryURINormalizer(
ArcanistRepositoryURINormalizer::TYPE_GIT,
$uri);
}
protected function newPublishedCommitHashes() {
$remotes = $this->newRemoteRefQuery()
->execute();
if (!$remotes) {
return array();
}
$markers = $this->newMarkerRefQuery()
->withIsRemoteCache(true)
->execute();
if (!$markers) {
return array();
}
$runtime = $this->getRuntime();
$workflow = $runtime->getCurrentWorkflow();
$workflow->loadHardpoints(
$remotes,
ArcanistRemoteRef::HARDPOINT_REPOSITORYREFS);
$remotes = mpull($remotes, null, 'getRemoteName');
$hashes = array();
foreach ($markers as $marker) {
$remote_name = $marker->getRemoteName();
$remote = idx($remotes, $remote_name);
if (!$remote) {
continue;
}
if (!$remote->isPermanentRef($marker)) {
continue;
}
$hashes[] = $marker->getCommitHash();
}
return $hashes;
}
protected function newCommitGraphQueryTemplate() {
return new ArcanistGitCommitGraphQuery();
}
}
diff --git a/src/repository/api/ArcanistMercurialAPI.php b/src/repository/api/ArcanistMercurialAPI.php
index 7e35a1e3..b27e87b2 100644
--- a/src/repository/api/ArcanistMercurialAPI.php
+++ b/src/repository/api/ArcanistMercurialAPI.php
@@ -1,1264 +1,1265 @@
<?php
/**
* Interfaces with the Mercurial working copies.
*/
final class ArcanistMercurialAPI extends ArcanistRepositoryAPI {
/**
* Mercurial deceptively indicates that the default encoding is UTF-8 however
* however the actual default appears to be "something else", at least on
* Windows systems. Force all mercurial commands to use UTF-8 encoding.
*/
const ROOT_HG_COMMAND = 'hg --encoding utf-8 ';
private $branch;
private $localCommitInfo;
private $rawDiffCache = array();
private $featureResults = array();
private $featureFutures = array();
protected function buildLocalFuture(array $argv) {
$argv[0] = self::ROOT_HG_COMMAND.$argv[0];
return $this->newConfiguredFuture(newv('ExecFuture', $argv));
}
public function newPassthru($pattern /* , ... */) {
$args = func_get_args();
$args[0] = self::ROOT_HG_COMMAND.$args[0];
return $this->newConfiguredFuture(newv('PhutilExecPassthru', $args));
}
private function newConfiguredFuture(PhutilExecutableFuture $future) {
$args = func_get_args();
$env = $this->getMercurialEnvironmentVariables();
return $future
->setEnv($env)
->setCWD($this->getPath());
}
public function getSourceControlSystemName() {
return 'hg';
}
public function getMetadataPath() {
return $this->getPath('.hg');
}
public function getSourceControlBaseRevision() {
return $this->getCanonicalRevisionName($this->getBaseCommit());
}
public function getCanonicalRevisionName($string) {
list($stdout) = $this->execxLocal(
'log -l 1 --template %s -r %s --',
'{node}',
$string);
return $stdout;
}
public function getSourceControlPath() {
return '/';
}
public function getBranchName() {
if (!$this->branch) {
list($stdout) = $this->execxLocal('branch');
$this->branch = trim($stdout);
}
return $this->branch;
}
protected function didReloadCommitRange() {
$this->localCommitInfo = null;
}
protected function buildBaseCommit($symbolic_commit) {
if ($symbolic_commit !== null) {
try {
$commit = $this->getCanonicalRevisionName(
hgsprintf('ancestor(%s,.)', $symbolic_commit));
} catch (Exception $ex) {
// Try it as a revset instead of a commit id
try {
$commit = $this->getCanonicalRevisionName(
hgsprintf('ancestor(%R,.)', $symbolic_commit));
} catch (Exception $ex) {
throw new ArcanistUsageException(
pht(
"Commit '%s' is not a valid Mercurial commit identifier.",
$symbolic_commit));
}
}
$this->setBaseCommitExplanation(
pht(
'it is the greatest common ancestor of the working directory '.
'and the commit you specified explicitly.'));
return $commit;
}
if ($this->getBaseCommitArgumentRules() ||
$this->getConfigurationManager()->getConfigFromAnySource('base')) {
$base = $this->resolveBaseCommit();
if (!$base) {
throw new ArcanistUsageException(
pht(
"None of the rules in your 'base' configuration matched a valid ".
"commit. Adjust rules or specify which commit you want to use ".
"explicitly."));
}
return $base;
}
list($err, $stdout) = $this->execManualLocal(
'log --branch %s -r %s --style default',
$this->getBranchName(),
'draft()');
if (!$err) {
$logs = ArcanistMercurialParser::parseMercurialLog($stdout);
} else {
// Mercurial (in some versions?) raises an error when there's nothing
// outgoing.
$logs = array();
}
if (!$logs) {
$this->setBaseCommitExplanation(
pht(
'you have no outgoing commits, so arc assumes you intend to submit '.
'uncommitted changes in the working copy.'));
return $this->getWorkingCopyRevision();
}
$outgoing_revs = ipull($logs, 'rev');
// This is essentially an implementation of a theoretical `hg merge-base`
// command.
$against = $this->getWorkingCopyRevision();
while (true) {
// NOTE: The "^" and "~" syntaxes were only added in hg 1.9, which is
// new as of July 2011, so do this in a compatible way. Also, "hg log"
// and "hg outgoing" don't necessarily show parents (even if given an
// explicit template consisting of just the parents token) so we need
// to separately execute "hg parents".
list($stdout) = $this->execxLocal(
'parents --style default --rev %s',
$against);
$parents_logs = ArcanistMercurialParser::parseMercurialLog($stdout);
list($p1, $p2) = array_merge($parents_logs, array(null, null));
if ($p1 && !in_array($p1['rev'], $outgoing_revs)) {
$against = $p1['rev'];
break;
} else if ($p2 && !in_array($p2['rev'], $outgoing_revs)) {
$against = $p2['rev'];
break;
} else if ($p1) {
$against = $p1['rev'];
} else {
// This is the case where you have a new repository and the entire
// thing is outgoing; Mercurial literally accepts "--rev null" as
// meaning "diff against the empty state".
$against = 'null';
break;
}
}
if ($against == 'null') {
$this->setBaseCommitExplanation(
pht('this is a new repository (all changes are outgoing).'));
} else {
$this->setBaseCommitExplanation(
pht(
'it is the first commit reachable from the working copy state '.
'which is not outgoing.'));
}
return $against;
}
public function getLocalCommitInformation() {
if ($this->localCommitInfo === null) {
$base_commit = $this->getBaseCommit();
list($info) = $this->execxLocal(
'log --template %s --rev %s --branch %s --',
"{node}\1{rev}\1{author}\1".
"{date|rfc822date}\1{branch}\1{tag}\1{parents}\1{desc}\2",
hgsprintf('(%s::. - %s)', $base_commit, $base_commit),
$this->getBranchName());
$logs = array_filter(explode("\2", $info));
$last_node = null;
$futures = array();
$commits = array();
foreach ($logs as $log) {
list($node, $rev, $full_author, $date, $branch, $tag,
$parents, $desc) = explode("\1", $log, 9);
list($author, $author_email) = $this->parseFullAuthor($full_author);
// NOTE: If a commit has only one parent, {parents} returns empty.
// If it has two parents, {parents} returns revs and short hashes, not
// full hashes. Try to avoid making calls to "hg parents" because it's
// relatively expensive.
$commit_parents = null;
if (!$parents) {
if ($last_node) {
$commit_parents = array($last_node);
}
}
if (!$commit_parents) {
// We didn't get a cheap hit on previous commit, so do the full-cost
// "hg parents" call. We can run these in parallel, at least.
$futures[$node] = $this->execFutureLocal(
'parents --template %s --rev %s',
'{node}\n',
$node);
}
$commits[$node] = array(
'author' => $author,
'time' => strtotime($date),
'branch' => $branch,
'tag' => $tag,
'commit' => $node,
'rev' => $node, // TODO: Remove eventually.
'local' => $rev,
'parents' => $commit_parents,
'summary' => head(explode("\n", $desc)),
'message' => $desc,
'authorEmail' => $author_email,
);
$last_node = $node;
}
$futures = id(new FutureIterator($futures))
->limit(4);
foreach ($futures as $node => $future) {
list($parents) = $future->resolvex();
$parents = array_filter(explode("\n", $parents));
$commits[$node]['parents'] = $parents;
}
// Put commits in newest-first order, to be consistent with Git and the
// expected order of "hg log" and "git log" under normal circumstances.
// The order of ancestors() is oldest-first.
$commits = array_reverse($commits);
$this->localCommitInfo = $commits;
}
return $this->localCommitInfo;
}
public function getAllFiles() {
// TODO: Handle paths with newlines.
$future = $this->buildLocalFuture(array('manifest'));
return new LinesOfALargeExecFuture($future);
}
public function getChangedFiles($since_commit) {
list($stdout) = $this->execxLocal(
'status --rev %s',
$since_commit);
return ArcanistMercurialParser::parseMercurialStatus($stdout);
}
public function getBlame($path) {
list($stdout) = $this->execxLocal(
'annotate -u -v -c --rev %s -- %s',
$this->getBaseCommit(),
$path);
$lines = phutil_split_lines($stdout, $retain_line_endings = true);
$blame = array();
foreach ($lines as $line) {
if (!strlen($line)) {
continue;
}
$matches = null;
$ok = preg_match('/^\s*([^:]+?) ([a-f0-9]{12}):/', $line, $matches);
if (!$ok) {
throw new Exception(
pht(
'Unable to parse Mercurial blame line: %s',
$line));
}
$revision = $matches[2];
$author = trim($matches[1]);
$blame[] = array($author, $revision);
}
return $blame;
}
protected function buildUncommittedStatus() {
list($stdout) = $this->execxLocal('status');
$results = new PhutilArrayWithDefaultValue();
$working_status = ArcanistMercurialParser::parseMercurialStatus($stdout);
foreach ($working_status as $path => $mask) {
if (!($mask & parent::FLAG_UNTRACKED)) {
// Mark tracked files as uncommitted.
$mask |= self::FLAG_UNCOMMITTED;
}
$results[$path] |= $mask;
}
return $results->toArray();
}
protected function buildCommitRangeStatus() {
list($stdout) = $this->execxLocal(
'status --rev %s --rev tip',
$this->getBaseCommit());
$results = new PhutilArrayWithDefaultValue();
$working_status = ArcanistMercurialParser::parseMercurialStatus($stdout);
foreach ($working_status as $path => $mask) {
$results[$path] |= $mask;
}
return $results->toArray();
}
protected function didReloadWorkingCopy() {
// Diffs are against ".", so we need to drop the cache if we change the
// working copy.
$this->rawDiffCache = array();
$this->branch = null;
}
private function getDiffOptions() {
$options = array(
'--git',
'-U'.$this->getDiffLinesOfContext(),
);
return implode(' ', $options);
}
public function getRawDiffText($path) {
$options = $this->getDiffOptions();
$range = $this->getBaseCommit();
$raw_diff_cache_key = $options.' '.$range.' '.$path;
if (idx($this->rawDiffCache, $raw_diff_cache_key)) {
return idx($this->rawDiffCache, $raw_diff_cache_key);
}
list($stdout) = $this->execxLocal(
'diff %C --rev %s -- %s',
$options,
$range,
$path);
$this->rawDiffCache[$raw_diff_cache_key] = $stdout;
return $stdout;
}
public function getFullMercurialDiff() {
return $this->getRawDiffText('');
}
public function getOriginalFileData($path) {
return $this->getFileDataAtRevision($path, $this->getBaseCommit());
}
public function getCurrentFileData($path) {
return $this->getFileDataAtRevision(
$path,
$this->getWorkingCopyRevision());
}
public function getBulkOriginalFileData($paths) {
return $this->getBulkFileDataAtRevision($paths, $this->getBaseCommit());
}
public function getBulkCurrentFileData($paths) {
return $this->getBulkFileDataAtRevision(
$paths,
$this->getWorkingCopyRevision());
}
private function getBulkFileDataAtRevision($paths, $revision) {
// Calling 'hg cat' on each file individually is slow (1 second per file
// on a large repo) because mercurial has to decompress and parse the
// entire manifest every time. Do it in one large batch instead.
// hg cat will write the file data to files in a temp directory
$tmpdir = Filesystem::createTemporaryDirectory();
// Mercurial doesn't create the directories for us :(
foreach ($paths as $path) {
$tmppath = $tmpdir.'/'.$path;
Filesystem::createDirectory(dirname($tmppath), 0755, true);
}
// NOTE: The "%s%%p" construction passes a literal "%p" to Mercurial,
// which is a formatting directive for a repo-relative filepath. The
// particulars of the construction avoid Windows escaping issues. See
// PHI904.
list($err, $stdout) = $this->execManualLocal(
'cat --rev %s --output %s%%p -- %Ls',
$revision,
$tmpdir.DIRECTORY_SEPARATOR,
$paths);
$filedata = array();
foreach ($paths as $path) {
$tmppath = $tmpdir.'/'.$path;
if (Filesystem::pathExists($tmppath)) {
$filedata[$path] = Filesystem::readFile($tmppath);
}
}
Filesystem::remove($tmpdir);
return $filedata;
}
private function getFileDataAtRevision($path, $revision) {
list($err, $stdout) = $this->execManualLocal(
'cat --rev %s -- %s',
$revision,
$path);
if ($err) {
// Assume this is "no file at revision", i.e. a deleted or added file.
return null;
} else {
return $stdout;
}
}
protected function newCurrentCommitSymbol() {
return $this->getWorkingCopyRevision();
}
public function getWorkingCopyRevision() {
return '.';
}
public function isHistoryDefaultImmutable() {
return true;
}
public function supportsAmend() {
list($err, $stdout) = $this->execManualLocal('help commit');
if ($err) {
return false;
} else {
return (strpos($stdout, 'amend') !== false);
}
}
public function supportsCommitRanges() {
return true;
}
public function supportsLocalCommits() {
return true;
}
public function getBaseCommitRef() {
$base_commit = $this->getBaseCommit();
if ($base_commit === 'null') {
return null;
}
$base_message = $this->getCommitMessage($base_commit);
return $this->newCommitRef()
->setCommitHash($base_commit)
->attachMessage($base_message);
}
public function hasLocalCommit($commit) {
try {
$this->getCanonicalRevisionName($commit);
return true;
} catch (Exception $ex) {
return false;
}
}
public function getCommitMessage($commit) {
list($message) = $this->execxLocal(
'log --template={desc} --rev %s',
$commit);
return $message;
}
public function getAllLocalChanges() {
$diff = $this->getFullMercurialDiff();
if (!strlen(trim($diff))) {
return array();
}
$parser = new ArcanistDiffParser();
return $parser->parseDiff($diff);
}
public function getFinalizedRevisionMessage() {
return pht(
"You may now push this commit upstream, as appropriate (e.g. with ".
"'%s' or by printing and faxing it).",
'hg push');
}
public function getCommitMessageLog() {
$base_commit = $this->getBaseCommit();
list($stdout) = $this->execxLocal(
'log --template %s --rev %s --branch %s --',
"{node}\1{desc}\2",
hgsprintf('(%s::. - %s)', $base_commit, $base_commit),
$this->getBranchName());
$map = array();
$logs = explode("\2", trim($stdout));
foreach (array_filter($logs) as $log) {
list($node, $desc) = explode("\1", $log);
$map[$node] = $desc;
}
return array_reverse($map);
}
public function loadWorkingCopyDifferentialRevisions(
ConduitClient $conduit,
array $query) {
$messages = $this->getCommitMessageLog();
$parser = new ArcanistDiffParser();
// First, try to find revisions by explicit revision IDs in commit messages.
$reason_map = array();
$revision_ids = array();
foreach ($messages as $node_id => $message) {
$object = ArcanistDifferentialCommitMessage::newFromRawCorpus($message);
if ($object->getRevisionID()) {
$revision_ids[] = $object->getRevisionID();
$reason_map[$object->getRevisionID()] = $node_id;
}
}
if ($revision_ids) {
$results = $conduit->callMethodSynchronous(
'differential.query',
$query + array(
'ids' => $revision_ids,
));
foreach ($results as $key => $result) {
$hash = substr($reason_map[$result['id']], 0, 16);
$results[$key]['why'] =
pht(
"Commit message for '%s' has explicit 'Differential Revision'.",
$hash);
}
return $results;
}
// Try to find revisions by hash.
$hashes = array();
foreach ($this->getLocalCommitInformation() as $commit) {
$hashes[] = array('hgcm', $commit['commit']);
}
if ($hashes) {
// NOTE: In the case of "arc diff . --uncommitted" in a Mercurial working
// copy with dirty changes, there may be no local commits.
$results = $conduit->callMethodSynchronous(
'differential.query',
$query + array(
'commitHashes' => $hashes,
));
foreach ($results as $key => $hash) {
$results[$key]['why'] = pht(
'A mercurial commit hash in the commit range is already attached '.
'to the Differential revision.');
}
return $results;
}
return array();
}
public function updateWorkingCopy() {
$this->execxLocal('up');
$this->reloadWorkingCopy();
}
private function getMercurialConfig($key, $default = null) {
list($stdout) = $this->execxLocal('showconfig %s', $key);
if ($stdout == '') {
return $default;
}
return rtrim($stdout);
}
public function getAuthor() {
$full_author = $this->getMercurialConfig('ui.username');
list($author, $author_email) = $this->parseFullAuthor($full_author);
return $author;
}
/**
* Parse the Mercurial author field.
*
* Not everyone enters their email address as a part of the username
* field. Try to make it work when it's obvious.
*
* @param string $full_author
* @return array
*/
protected function parseFullAuthor($full_author) {
if (strpos($full_author, '@') === false) {
$author = $full_author;
$author_email = null;
} else {
$email = new PhutilEmailAddress($full_author);
$author = $email->getDisplayName();
$author_email = $email->getAddress();
}
return array($author, $author_email);
}
public function addToCommit(array $paths) {
$this->execxLocal(
'addremove -- %Ls',
$paths);
$this->reloadWorkingCopy();
}
public function doCommit($message) {
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
$this->execxLocal('commit --logfile %s', $tmp_file);
$this->reloadWorkingCopy();
}
public function amendCommit($message = null) {
$path_statuses = $this->buildUncommittedStatus();
$existing_message = $this->getCommitMessage(
$this->getWorkingCopyRevision());
if ($message === null || $message == $existing_message) {
if (empty($path_statuses)) {
// If there are no changes to the working directory and the message is
// not being changed then there's nothing to amend. Notably Mercurial
// will return an error code if trying to amend a commit with no change
// to the commit metadata or file changes.
return;
}
$message = $this->getCommitMessage('.');
}
$tmp_file = new TempFile();
Filesystem::writeFile($tmp_file, $message);
if ($this->getMercurialFeature('evolve')) {
$this->execxLocal('amend --logfile %s --', $tmp_file);
try {
$this->execxLocal('evolve --all --');
} catch (CommandException $ex) {
$this->execxLocal('evolve --abort --');
throw $ex;
}
$this->reloadWorkingCopy();
return;
}
// Get the child nodes of the current changeset.
list($children) = $this->execxLocal(
'log --template %s --rev %s --',
'{node} ',
'children(.)');
$child_nodes = array_filter(explode(' ', $children));
// For a head commit we can simply use `commit --amend` for both new commit
// message and amending changes from the working directory.
if (empty($child_nodes)) {
$this->execxLocal('commit --amend --logfile %s --', $tmp_file);
} else {
$this->amendNonHeadCommit($child_nodes, $tmp_file);
}
$this->reloadWorkingCopy();
}
/**
* Amends a non-head commit with a new message and file changes. This
* strategy is for Mercurial repositories without the evolve extension.
*
* 1. Run 'arc-amend' which uses Mercurial internals to amend the current
* commit with updated message/file-changes. It results in a new commit
* from the right parent
* 2. For each branch from the original commit, rebase onto the new commit,
* removing the original branch. Note that there is potential for this to
* cause a conflict but this is something the user has to address.
* 3. Strip the original commit.
*
- * @param array The list of child changesets off the original commit.
- * @param file The file containing the new commit message.
+ * @param array $child_nodes The list of child changesets off the original
+ * commit.
+ * @param file $tmp_file The file containing the new commit message.
*/
private function amendNonHeadCommit($child_nodes, $tmp_file) {
list($current) = $this->execxLocal(
'log --template %s --rev . --',
'{node}');
$this->execxLocalWithExtension(
'arc-hg',
'arc-amend --logfile %s',
$tmp_file);
list($new_commit) = $this->execxLocal(
'log --rev tip --template %s --',
'{node}');
try {
$rebase_args = array(
'--dest',
$new_commit,
);
foreach ($child_nodes as $child) {
$rebase_args[] = '--source';
$rebase_args[] = $child;
}
$this->execxLocalWithExtension(
'rebase',
'rebase %Ls --',
$rebase_args);
} catch (CommandException $ex) {
$this->execxLocalWithExtension(
'rebase',
'rebase --abort --');
throw $ex;
}
$this->execxLocalWithExtension(
'strip',
'strip --rev %s --',
$current);
}
public function getCommitSummary($commit) {
if ($commit == 'null') {
return pht('(The Empty Void)');
}
list($summary) = $this->execxLocal(
'log --template {desc} --limit 1 --rev %s',
$commit);
$summary = head(explode("\n", $summary));
return trim($summary);
}
public function resolveBaseCommitRule($rule, $source) {
list($type, $name) = explode(':', $rule, 2);
// NOTE: This function MUST return node hashes or symbolic commits (like
// branch names or the word "tip"), not revsets. This includes ".^" and
// similar, which a revset, not a symbolic commit identifier. If you return
// a revset it will be escaped later and looked up literally.
switch ($type) {
case 'hg':
$matches = null;
if (preg_match('/^gca\((.+)\)$/', $name, $matches)) {
list($err, $merge_base) = $this->execManualLocal(
'log --template={node} --rev %s',
sprintf('ancestor(., %s)', $matches[1]));
if (!$err) {
$this->setBaseCommitExplanation(
pht(
"it is the greatest common ancestor of '%s' and %s, as ".
"specified by '%s' in your %s 'base' configuration.",
$matches[1],
'.',
$rule,
$source));
return trim($merge_base);
}
} else {
list($err, $commit) = $this->execManualLocal(
'log --template {node} --rev %s',
hgsprintf('%s', $name));
if ($err) {
list($err, $commit) = $this->execManualLocal(
'log --template {node} --rev %s',
$name);
}
if (!$err) {
$this->setBaseCommitExplanation(
pht(
"it is specified by '%s' in your %s 'base' configuration.",
$rule,
$source));
return trim($commit);
}
}
break;
case 'arc':
switch ($name) {
case 'empty':
$this->setBaseCommitExplanation(
pht(
"you specified '%s' in your %s 'base' configuration.",
$rule,
$source));
return 'null';
case 'outgoing':
list($err, $outgoing_base) = $this->execManualLocal(
'log --template={node} --rev %s',
'limit(reverse(ancestors(.) - outgoing()), 1)');
if (!$err) {
$this->setBaseCommitExplanation(
pht(
"it is the first ancestor of the working copy that is not ".
"outgoing, and it matched the rule %s in your %s ".
"'base' configuration.",
$rule,
$source));
return trim($outgoing_base);
}
case 'amended':
$text = $this->getCommitMessage('.');
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$text);
if ($message->getRevisionID()) {
$this->setBaseCommitExplanation(
pht(
"'%s' has been amended with 'Differential Revision:', ".
"as specified by '%s' in your %s 'base' configuration.",
'.',
$rule,
$source));
// NOTE: This should be safe because Mercurial doesn't support
// amend until 2.2.
return $this->getCanonicalRevisionName('.^');
}
break;
case 'bookmark':
$revset =
'limit('.
' sort('.
' (ancestors(.) and bookmark() - .) or'.
' (ancestors(.) - outgoing()), '.
' -rev),'.
'1)';
list($err, $bookmark_base) = $this->execManualLocal(
'log --template={node} --rev %s',
$revset);
if (!$err) {
$this->setBaseCommitExplanation(
pht(
"it is the first ancestor of %s that either has a bookmark, ".
"or is already in the remote and it matched the rule %s in ".
"your %s 'base' configuration",
'.',
$rule,
$source));
return trim($bookmark_base);
}
break;
case 'this':
$this->setBaseCommitExplanation(
pht(
"you specified '%s' in your %s 'base' configuration.",
$rule,
$source));
return $this->getCanonicalRevisionName('.^');
default:
if (preg_match('/^nodiff\((.+)\)$/', $name, $matches)) {
list($results) = $this->execxLocal(
'log --template %s --rev %s',
"{node}\1{desc}\2",
sprintf('ancestor(.,%s)::.^', $matches[1]));
$results = array_reverse(explode("\2", trim($results)));
foreach ($results as $result) {
if (empty($result)) {
continue;
}
list($node, $desc) = explode("\1", $result, 2);
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$desc);
if ($message->getRevisionID()) {
$this->setBaseCommitExplanation(
pht(
"it is the first ancestor of %s that has a diff and is ".
"the gca or a descendant of the gca with '%s', ".
"specified by '%s' in your %s 'base' configuration.",
'.',
$matches[1],
$rule,
$source));
return $node;
}
}
}
break;
}
break;
default:
return null;
}
return null;
}
public function getSubversionInfo() {
$info = array();
$base_path = null;
$revision = null;
list($err, $raw_info) = $this->execManualLocal('svn info');
if (!$err) {
foreach (explode("\n", trim($raw_info)) as $line) {
list($key, $value) = explode(': ', $line, 2);
switch ($key) {
case 'URL':
$info['base_path'] = $value;
$base_path = $value;
break;
case 'Repository UUID':
$info['uuid'] = $value;
break;
case 'Revision':
$revision = $value;
break;
default:
break;
}
}
if ($base_path && $revision) {
$info['base_revision'] = $base_path.'@'.$revision;
}
}
return $info;
}
public function getActiveBookmark() {
$bookmark = $this->newMarkerRefQuery()
->withMarkerTypes(
array(
ArcanistMarkerRef::TYPE_BOOKMARK,
))
->withIsActive(true)
->executeOne();
if (!$bookmark) {
return null;
}
return $bookmark->getName();
}
public function getRemoteURI() {
// TODO: Remove this method in favor of RemoteRefQuery.
list($stdout) = $this->execxLocal('paths default');
$stdout = trim($stdout);
if (strlen($stdout)) {
return $stdout;
}
return null;
}
private function getMercurialEnvironmentVariables() {
$env = array();
// Mercurial has a "defaults" feature which basically breaks automation by
// allowing the user to add random flags to any command. This feature is
// "deprecated" and "a bad idea" that you should "forget ... existed"
// according to project lead Matt Mackall:
//
// http://markmail.org/message/hl3d6eprubmkkqh5
//
// There is an HGPLAIN environmental variable which enables "plain mode"
// and hopefully disables this stuff.
$env['HGPLAIN'] = 1;
return $env;
}
protected function newLandEngine() {
return new ArcanistMercurialLandEngine();
}
protected function newWorkEngine() {
return new ArcanistMercurialWorkEngine();
}
public function newLocalState() {
return id(new ArcanistMercurialLocalState())
->setRepositoryAPI($this);
}
public function willTestMercurialFeature($feature) {
$this->executeMercurialFeatureTest($feature, false);
return $this;
}
public function getMercurialFeature($feature) {
return $this->executeMercurialFeatureTest($feature, true);
}
/**
* Returns the necessary flag for using a Mercurial extension. This will
* enable Mercurial built-in extensions and the "arc-hg" extension that is
* included with Arcanist. This will not enable other extensions, e.g.
* "evolve".
*
- * @param string The name of the extension to enable.
+ * @param string $extension The name of the extension to enable.
* @return string A new command pattern that includes the necessary flags to
* enable the specified extension.
*/
private function getMercurialExtensionFlag($extension) {
switch ($extension) {
case 'arc-hg':
$path = phutil_get_library_root('arcanist');
$path = dirname($path);
$path = $path.'/support/hg/arc-hg.py';
$ext_config = 'extensions.arc-hg='.$path;
break;
case 'rebase':
$ext_config = 'extensions.rebase=';
break;
case 'shelve':
$ext_config = 'extensions.shelve=';
break;
case 'strip':
$ext_config = 'extensions.strip=';
break;
default:
throw new Exception(
pht('Unknown Mercurial Extension: "%s".', $extension));
}
return csprintf('--config %s', $ext_config);
}
/**
* Produces the arguments that should be passed to Mercurial command
* execution that enables a desired extension.
*
- * @param string The name of the extension to enable.
- * @param string The command pattern that will be run with the extension
- * enabled.
- * @param array Parameters for the command pattern argument.
+ * @param string $extension The name of the extension to enable.
+ * @param string $pattern The command pattern that will be run with the
+ * extension enabled.
+ * @param array ... Parameters for the command pattern argument.
* @return array An array where the first item is a Mercurial command
* pattern that includes the necessary flag for enabling the
* desired extension, and all remaining items are parameters
* to that command pattern.
*/
private function buildMercurialExtensionCommand(
$extension,
$pattern /* , ... */) {
$args = func_get_args();
$pattern_args = array_slice($args, 2);
$ext_flag = $this->getMercurialExtensionFlag($extension);
$full_cmd = $ext_flag.' '.$pattern;
$args = array_merge(
array($full_cmd),
$pattern_args);
return $args;
}
public function execxLocalWithExtension(
$extension,
$pattern /* , ... */) {
$args = func_get_args();
$extended_args = call_user_func_array(
array($this, 'buildMercurialExtensionCommand'),
$args);
return call_user_func_array(
array($this, 'execxLocal'),
$extended_args);
}
public function execFutureLocalWithExtension(
$extension,
$pattern /* , ... */) {
$args = func_get_args();
$extended_args = call_user_func_array(
array($this, 'buildMercurialExtensionCommand'),
$args);
return call_user_func_array(
array($this, 'execFutureLocal'),
$extended_args);
}
public function execPassthruWithExtension(
$extension,
$pattern /* , ... */) {
$args = func_get_args();
$extended_args = call_user_func_array(
array($this, 'buildMercurialExtensionCommand'),
$args);
return call_user_func_array(
array($this, 'execPassthru'),
$extended_args);
}
public function execManualLocalWithExtension(
$extension,
$pattern /* , ... */) {
$args = func_get_args();
$extended_args = call_user_func_array(
array($this, 'buildMercurialExtensionCommand'),
$args);
return call_user_func_array(
array($this, 'execManualLocal'),
$extended_args);
}
private function executeMercurialFeatureTest($feature, $resolve) {
if (array_key_exists($feature, $this->featureResults)) {
return $this->featureResults[$feature];
}
if (!array_key_exists($feature, $this->featureFutures)) {
$future = $this->newMercurialFeatureFuture($feature);
$future->start();
$this->featureFutures[$feature] = $future;
}
if (!$resolve) {
return;
}
$future = $this->featureFutures[$feature];
$result = $this->resolveMercurialFeatureFuture($feature, $future);
$this->featureResults[$feature] = $result;
return $result;
}
private function newMercurialFeatureFuture($feature) {
switch ($feature) {
case 'shelve':
return $this->execFutureLocalWithExtension(
'shelve',
'shelve --help --');
case 'evolve':
return $this->execFutureLocal('prune --help --');
default:
throw new Exception(
pht(
'Unknown Mercurial feature "%s".',
$feature));
}
}
private function resolveMercurialFeatureFuture($feature, $future) {
// By default, assume the feature is a simple capability test and the
// capability is present if the feature resolves without an error.
list($err) = $future->resolve();
return !$err;
}
protected function newSupportedMarkerTypes() {
return array(
ArcanistMarkerRef::TYPE_BRANCH,
ArcanistMarkerRef::TYPE_BOOKMARK,
);
}
protected function newMarkerRefQueryTemplate() {
return new ArcanistMercurialRepositoryMarkerQuery();
}
protected function newRemoteRefQueryTemplate() {
return new ArcanistMercurialRepositoryRemoteQuery();
}
protected function newNormalizedURI($uri) {
return new ArcanistRepositoryURINormalizer(
ArcanistRepositoryURINormalizer::TYPE_MERCURIAL,
$uri);
}
protected function newCommitGraphQueryTemplate() {
return new ArcanistMercurialCommitGraphQuery();
}
protected function newPublishedCommitHashes() {
$future = $this->newFuture(
'log --rev %s --template %s',
hgsprintf('parents(draft()) - draft()'),
'{node}\n');
list($lines) = $future->resolve();
$lines = phutil_split_lines($lines, false);
$hashes = array();
foreach ($lines as $line) {
if (!strlen(trim($line))) {
continue;
}
$hashes[] = $line;
}
return $hashes;
}
}
diff --git a/src/repository/api/ArcanistRepositoryAPI.php b/src/repository/api/ArcanistRepositoryAPI.php
index 48b44f66..dd35db2f 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
* @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 Scratch file name.
+ * @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 Scratch file name to write.
- * @param string Data to write.
+ * @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 Scratch file name to remove.
+ * @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 Scratch file name.
+ * @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 Scratch file name.
+ * @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/repository/parser/ArcanistMercurialParser.php b/src/repository/parser/ArcanistMercurialParser.php
index 33e59c5c..96ad15e6 100644
--- a/src/repository/parser/ArcanistMercurialParser.php
+++ b/src/repository/parser/ArcanistMercurialParser.php
@@ -1,241 +1,241 @@
<?php
/**
* Parses output from various "hg" commands into structured data. This class
* provides low-level APIs for reading "hg" output.
*
* @task parse Parsing "hg" Output
*/
final class ArcanistMercurialParser extends Phobject {
/* -( Parsing "hg" Output )------------------------------------------------ */
/**
* Parse the output of "hg status". This provides detailed information, you
* can get less detailed information with @{method:parseMercurialStatus}. In
* particular, this will parse copy sources as per "hg status -C".
*
- * @param string The stdout from running an "hg status" command.
+ * @param string $stdout The stdout from running an "hg status" command.
* @return dict Map of paths to status dictionaries.
* @task parse
*/
public static function parseMercurialStatusDetails($stdout) {
$result = array();
$stdout = trim($stdout);
if (!strlen($stdout)) {
return $result;
}
$last_path = null;
$lines = explode("\n", $stdout);
foreach ($lines as $line) {
$flags = 0;
if ($line[1] !== ' ') {
throw new Exception(
pht(
"Unparsable Mercurial status line '%s'.",
$line));
}
$code = $line[0];
$path = substr($line, 2);
switch ($code) {
case 'A':
$flags |= ArcanistRepositoryAPI::FLAG_ADDED;
break;
case 'R':
$flags |= ArcanistRepositoryAPI::FLAG_DELETED;
break;
case 'M':
$flags |= ArcanistRepositoryAPI::FLAG_MODIFIED;
break;
case 'C':
// This is "clean" and included only for completeness, these files
// have not been changed.
break;
case '!':
$flags |= ArcanistRepositoryAPI::FLAG_MISSING;
break;
case '?':
$flags |= ArcanistRepositoryAPI::FLAG_UNTRACKED;
break;
case 'I':
// This is "ignored" and included only for completeness.
break;
case ' ':
// This shows the source of a file move, so update the last file we
// parsed to set its source.
if ($last_path === null) {
throw new Exception(
pht(
"Unexpected copy source in %s, '%s'.",
'hg status',
$line));
}
$result[$last_path]['from'] = $path;
continue 2;
default:
throw new Exception(pht("Unknown Mercurial status '%s'.", $code));
}
$result[$path] = array(
'flags' => $flags,
'from' => null,
);
$last_path = $path;
}
return $result;
}
/**
* Parse the output of "hg status". This provides only basic information, you
* can get more detailed information by invoking
* @{method:parseMercurialStatusDetails}.
*
- * @param string The stdout from running an "hg status" command.
+ * @param string $stdout The stdout from running an "hg status" command.
* @return dict Map of paths to ArcanistRepositoryAPI status flags.
* @task parse
*/
public static function parseMercurialStatus($stdout) {
$result = self::parseMercurialStatusDetails($stdout);
return ipull($result, 'flags');
}
/**
* Parse the output of "hg log". This also parses "hg outgoing", "hg parents",
* and other similar commands. This assumes "--style default".
*
- * @param string The stdout from running an "hg log" command.
+ * @param string $stdout The stdout from running an "hg log" command.
* @return list List of dictionaries with commit information.
* @task parse
*/
public static function parseMercurialLog($stdout) {
$result = array();
$stdout = trim($stdout);
if (!strlen($stdout)) {
return $result;
}
$chunks = explode("\n\n", $stdout);
foreach ($chunks as $chunk) {
$commit = array();
$lines = explode("\n", $chunk);
foreach ($lines as $line) {
if (preg_match('/^(comparing with|searching for changes)/', $line)) {
// These are sent to stdout when you run "hg outgoing" although the
// format is otherwise identical to "hg log".
continue;
}
if (preg_match('/^remote:/', $line)) {
// This indicates remote error in "hg outgoing".
continue;
}
list($name, $value) = explode(':', $line, 2);
$value = trim($value);
switch ($name) {
case 'user':
$commit['user'] = $value;
break;
case 'date':
$commit['date'] = strtotime($value);
break;
case 'summary':
$commit['summary'] = $value;
break;
case 'changeset':
list($local, $rev) = explode(':', $value, 2);
$commit['local'] = $local;
$commit['rev'] = $rev;
break;
case 'parent':
if (empty($commit['parents'])) {
$commit['parents'] = array();
}
list($local, $rev) = explode(':', $value, 2);
$commit['parents'][] = array(
'local' => $local,
'rev' => $rev,
);
break;
case 'branch':
$commit['branch'] = $value;
break;
case 'tag':
$commit['tag'] = $value;
break;
case 'bookmark':
$commit['bookmark'] = $value;
break;
case 'obsolete':
case 'instability':
// These are extra fields added by the "evolve" extension even
// if HGPLAIN=1 is set. See PHI502 and PHI718.
break;
default:
throw new Exception(
pht("Unknown Mercurial log field '%s'!", $name));
}
}
$result[] = $commit;
}
return $result;
}
/**
* Parse the output of "hg branches".
*
- * @param string The stdout from running an "hg branches" command.
+ * @param string $stdout The stdout from running an "hg branches" command.
* @return list A list of dictionaries with branch information.
* @task parse
*/
public static function parseMercurialBranches($stdout) {
$stdout = rtrim($stdout, "\n");
if (!strlen($stdout)) {
// No branches; commonly, this occurs in a newly initialized repository.
return array();
}
$lines = explode("\n", $stdout);
$branches = array();
foreach ($lines as $line) {
$matches = null;
// Output of "hg branches" normally looks like:
//
// default 15101:a21ccf4412d5
//
// ...but may also have human-readable cues like:
//
// stable 15095:ec222a29bdf0 (inactive)
//
// See the unit tests for more examples.
$regexp = '/^(\S+(?:\s+\S+)*)\s+(\d+):([a-f0-9]+)(\s+\\(inactive\\))?$/';
if (!preg_match($regexp, $line, $matches)) {
throw new Exception(
pht(
"Failed to parse '%s' output: %s",
'hg branches',
$line));
}
$branches[$matches[1]] = array(
'local' => $matches[2],
'rev' => $matches[3],
);
}
return $branches;
}
}
diff --git a/src/repository/state/ArcanistRepositoryLocalState.php b/src/repository/state/ArcanistRepositoryLocalState.php
index e2c50283..84521fc7 100644
--- a/src/repository/state/ArcanistRepositoryLocalState.php
+++ b/src/repository/state/ArcanistRepositoryLocalState.php
@@ -1,277 +1,277 @@
<?php
abstract class ArcanistRepositoryLocalState
extends Phobject {
private $repositoryAPI;
private $shouldRestore;
private $stashRef;
private $workflow;
final public function setWorkflow(ArcanistWorkflow $workflow) {
$this->workflow = $workflow;
return $this;
}
final public function getWorkflow() {
return $this->workflow;
}
final public function setRepositoryAPI(ArcanistRepositoryAPI $api) {
$this->repositoryAPI = $api;
return $this;
}
final public function getRepositoryAPI() {
return $this->repositoryAPI;
}
final public function saveLocalState() {
$api = $this->getRepositoryAPI();
$working_copy_display = tsprintf(
" %s: %s\n",
pht('Working Copy'),
$api->getPath());
$conflicts = $api->getMergeConflicts();
if ($conflicts) {
echo tsprintf(
"\n%!\n%W\n\n%s\n",
pht('MERGE CONFLICTS'),
pht('You have merge conflicts in this working copy.'),
$working_copy_display);
$lists = array();
$lists[] = $this->newDisplayFileList(
pht('Merge conflicts in working copy:'),
$conflicts);
$this->printFileLists($lists);
throw new PhutilArgumentUsageException(
pht(
'Resolve merge conflicts before proceeding.'));
}
$externals = $api->getDirtyExternalChanges();
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();
$untracked = $api->getUntrackedChanges();
// 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);
if ($untracked || $unstaged || $uncommitted) {
echo tsprintf(
"\n%!\n%W\n\n%s\n",
pht('UNCOMMITTED CHANGES'),
pht('You have uncommitted changes in this working copy.'),
$working_copy_display);
$lists = array();
$lists[] = $this->newDisplayFileList(
pht('Untracked changes in working copy:'),
$untracked);
$lists[] = $this->newDisplayFileList(
pht('Unstaged changes in working copy:'),
$unstaged);
$lists[] = $this->newDisplayFileList(
pht('Uncommitted changes in working copy:'),
$uncommitted);
$this->printFileLists($lists);
if ($untracked) {
$hints = $this->getIgnoreHints();
foreach ($hints as $hint) {
echo tsprintf("%?\n", $hint);
}
}
if ($this->canStashChanges()) {
$query = pht('Stash these changes and continue?');
$this->getWorkflow()
->getPrompt('arc.state.stash')
->setQuery($query)
->execute();
$stash_ref = $this->saveStash();
if ($stash_ref === null) {
throw new Exception(
pht(
'Expected a non-null return from call to "%s->saveStash()".',
get_class($this)));
}
$this->stashRef = $stash_ref;
} else {
throw new PhutilArgumentUsageException(
pht(
'You can not continue with uncommitted changes. Commit or '.
'discard them before proceeding.'));
}
}
$this->executeSaveLocalState();
$this->shouldRestore = true;
// TODO: Detect when we're in the middle of a rebase.
// TODO: Detect when we're in the middle of a cherry-pick.
return $this;
}
final public function restoreLocalState() {
$this->shouldRestore = false;
$this->executeRestoreLocalState();
$this->applyStash();
$this->executeDiscardLocalState();
return $this;
}
final public function discardLocalState() {
$this->shouldRestore = false;
$this->applyStash();
$this->executeDiscardLocalState();
return $this;
}
final public function __destruct() {
if ($this->shouldRestore) {
$this->restoreLocalState();
} else {
$this->discardLocalState();
}
}
final public function getRestoreCommandsForDisplay() {
return $this->newRestoreCommandsForDisplay();
}
protected function canStashChanges() {
return false;
}
/**
* Stash uncommitted changes temporarily. Use {@method:restoreStash()} to
* bring these changes back.
*
* Note that saving and restoring changes may not behave as expected if used
* in a non-stack manner, i.e. proper use involves only restoring stashes in
* the reverse order they were saved.
*
* @return wild A reference object that refers to the changes which were
* saved. When restoring changes this should be passed to
* {@method:restoreStash()}.
*/
protected function saveStash() {
throw new PhutilMethodNotImplementedException();
}
/**
* Restores changes that were previously stashed by {@method:saveStash()}.
*
- * @param wild A reference object referring to which previously stashed
+ * @param wild $ref A reference object referring to which previously stashed
* changes to restore, from invoking {@method:saveStash()}.
*/
protected function restoreStash($ref) {
throw new PhutilMethodNotImplementedException();
}
protected function discardStash($ref) {
throw new PhutilMethodNotImplementedException();
}
private function applyStash() {
if ($this->stashRef === null) {
return;
}
$stash_ref = $this->stashRef;
$this->stashRef = null;
$this->restoreStash($stash_ref);
$this->discardStash($stash_ref);
}
abstract protected function executeSaveLocalState();
abstract protected function executeRestoreLocalState();
abstract protected function executeDiscardLocalState();
abstract protected function newRestoreCommandsForDisplay();
protected function getIgnoreHints() {
return array();
}
final protected function newDisplayFileList($title, array $files) {
if (!$files) {
return null;
}
$items = array();
$items[] = tsprintf("%s\n\n", $title);
foreach ($files as $file) {
$items[] = tsprintf(
" %s\n",
$file);
}
return $items;
}
final protected function printFileLists(array $lists) {
$lists = array_filter($lists);
$last_key = last_key($lists);
foreach ($lists as $key => $list) {
foreach ($list as $item) {
echo tsprintf('%B', $item);
}
if ($key !== $last_key) {
echo tsprintf("\n\n");
}
}
echo tsprintf("\n");
}
}
diff --git a/src/symbols/PhutilClassMapQuery.php b/src/symbols/PhutilClassMapQuery.php
index 62d567d6..702ba441 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 Ancestor class or interface name.
+ * @param string $class Ancestor class or interface name.
* @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 Name of the unique key method.
- * @param bool If true, then classes which return `null` will be filtered
- * from the results.
+ * @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
* @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 Name of the expansion method.
+ * @param string $expand_method Name of the expansion method.
* @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 Name of the sorting method.
+ * @param string $sort_method Name of the sorting method.
* @return this
* @task config
*/
public function setSortMethod($sort_method) {
$this->sortMethod = $sort_method;
return $this;
}
/**
* Provide a method to filter the map.
*
- * @param string Name of the filtering method.
+ * @param string $filter_method Name of the filtering method.
* @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 7f733ee7..795b0803 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 of symbol to load.
+ * @param string $type Type of symbol to load.
* @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 name.
+ * @param string $library Library name.
* @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 relative to library root, like "apps/cheese/".
+ * @param string $path Path relative to library root, like "apps/cheese/".
* @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 Symbol name.
+ * @param string $name Symbol name.
* @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 class name.
+ * @param string $base Base class name.
* @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 True if the query should load only concrete symbols.
+ * @param bool $concrete True if the query should load only concrete symbols.
* @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> List of constructor arguments.
+ * @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 ec25c20f..e40df693 100644
--- a/src/unit/ArcanistUnitTestResult.php
+++ b/src/unit/ArcanistUnitTestResult.php
@@ -1,241 +1,241 @@
<?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, in seconds.
+ * @param int|float $duration Duration, in seconds.
* @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 List of coverage report strings.
+ * @param list $coverage List of coverage report strings.
* @return string Cumulative coverage report.
*/
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/unit/engine/CSharpToolsTestEngine.php b/src/unit/engine/CSharpToolsTestEngine.php
index 7fd23e0d..746c3739 100644
--- a/src/unit/engine/CSharpToolsTestEngine.php
+++ b/src/unit/engine/CSharpToolsTestEngine.php
@@ -1,287 +1,287 @@
<?php
/**
* Uses cscover (http://github.com/hach-que/cstools) to report code coverage.
*
* This engine inherits from `XUnitTestEngine`, where xUnit is used to actually
* run the unit tests and this class provides a thin layer on top to collect
* code coverage data with a third-party tool.
*/
final class CSharpToolsTestEngine extends XUnitTestEngine {
private $cscoverHintPath;
private $coverEngine;
private $cachedResults;
private $matchRegex;
private $excludedFiles;
/**
* Overridden version of `loadEnvironment` to support a different set of
* configuration values and to pull in the cstools config for code coverage.
*/
protected function loadEnvironment() {
$config = $this->getConfigurationManager();
$this->cscoverHintPath = $config->getConfigFromAnySource(
'unit.csharp.cscover.binary');
$this->matchRegex = $config->getConfigFromAnySource(
'unit.csharp.coverage.match');
$this->excludedFiles = $config->getConfigFromAnySource(
'unit.csharp.coverage.excluded');
parent::loadEnvironment();
if ($this->getEnableCoverage() === false) {
return;
}
// Determine coverage path.
if ($this->cscoverHintPath === null) {
throw new Exception(
pht(
"Unable to locate %s. Configure it with the '%s' option in %s.",
'cscover',
'unit.csharp.coverage.binary',
'.arcconfig'));
}
$cscover = $this->projectRoot.DIRECTORY_SEPARATOR.$this->cscoverHintPath;
if (file_exists($cscover)) {
$this->coverEngine = Filesystem::resolvePath($cscover);
} else {
throw new Exception(
pht(
'Unable to locate %s coverage runner (have you built yet?)',
'cscover'));
}
}
/**
* Returns whether the specified assembly should be instrumented for
* code coverage reporting. Checks the excluded file list and the
* matching regex if they are configured.
*
* @return boolean Whether the assembly should be instrumented.
*/
private function assemblyShouldBeInstrumented($file) {
if ($this->excludedFiles !== null) {
if (array_key_exists((string)$file, $this->excludedFiles)) {
return false;
}
}
if ($this->matchRegex !== null) {
if (preg_match($this->matchRegex, $file) === 1) {
return true;
} else {
return false;
}
}
return true;
}
/**
* Overridden version of `buildTestFuture` so that the unit test can be run
* via `cscover`, which instruments assemblies and reports on code coverage.
*
- * @param string Name of the test assembly.
+ * @param string $test_assembly Name of the test assembly.
* @return array The future, output filename and coverage filename
* stored in an array.
*/
protected function buildTestFuture($test_assembly) {
if ($this->getEnableCoverage() === false) {
return parent::buildTestFuture($test_assembly);
}
// FIXME: Can't use TempFile here as xUnit doesn't like
// UNIX-style full paths. It sees the leading / as the
// start of an option flag, even when quoted.
$xunit_temp = Filesystem::readRandomCharacters(10).'.results.xml';
if (file_exists($xunit_temp)) {
unlink($xunit_temp);
}
$cover_temp = new TempFile();
$cover_temp->setPreserveFile(true);
$xunit_cmd = $this->runtimeEngine;
$xunit_args = null;
if ($xunit_cmd === '') {
$xunit_cmd = $this->testEngine;
$xunit_args = csprintf(
'%s /xml %s',
$test_assembly,
$xunit_temp);
} else {
$xunit_args = csprintf(
'%s %s /xml %s',
$this->testEngine,
$test_assembly,
$xunit_temp);
}
$assembly_dir = dirname($test_assembly);
$assemblies_to_instrument = array();
foreach (Filesystem::listDirectory($assembly_dir) as $file) {
if (substr($file, -4) == '.dll' || substr($file, -4) == '.exe') {
if ($this->assemblyShouldBeInstrumented($file)) {
$assemblies_to_instrument[] = $assembly_dir.DIRECTORY_SEPARATOR.$file;
}
}
}
if (count($assemblies_to_instrument) === 0) {
return parent::buildTestFuture($test_assembly);
}
$future = new ExecFuture(
'%C -o %s -c %s -a %s -w %s %Ls',
trim($this->runtimeEngine.' '.$this->coverEngine),
$cover_temp,
$xunit_cmd,
$xunit_args,
$assembly_dir,
$assemblies_to_instrument);
$future->setCWD(Filesystem::resolvePath($this->projectRoot));
return array(
$future,
$assembly_dir.DIRECTORY_SEPARATOR.$xunit_temp,
$cover_temp,
);
}
/**
* Returns coverage results for the unit tests.
*
- * @param string The name of the coverage file if one was provided by
- * `buildTestFuture`.
+ * @param string $cover_file The name of the coverage file if one was
+ * provided by `buildTestFuture`.
* @return array Code coverage results, or null.
*/
protected function parseCoverageResult($cover_file) {
if ($this->getEnableCoverage() === false) {
return parent::parseCoverageResult($cover_file);
}
return $this->readCoverage($cover_file);
}
/**
* Retrieves the cached results for a coverage result file. The coverage
* result file is XML and can be large depending on what has been instrumented
* so we cache it in case it's requested again.
*
- * @param string The name of the coverage file.
+ * @param string $cover_file The name of the coverage file.
* @return array Code coverage results, or null if not cached.
*/
private function getCachedResultsIfPossible($cover_file) {
if ($this->cachedResults == null) {
$this->cachedResults = array();
}
if (array_key_exists((string)$cover_file, $this->cachedResults)) {
return $this->cachedResults[(string)$cover_file];
}
return null;
}
/**
* Stores the code coverage results in the cache.
*
- * @param string The name of the coverage file.
- * @param array The results to cache.
+ * @param string $cover_file The name of the coverage file.
+ * @param array $results The results to cache.
*/
private function addCachedResults($cover_file, array $results) {
if ($this->cachedResults == null) {
$this->cachedResults = array();
}
$this->cachedResults[(string)$cover_file] = $results;
}
/**
* Processes a set of XML tags as code coverage results. We parse
* the `instrumented` and `executed` tags with this method so that
* we can access the data multiple times without a performance hit.
*
- * @param array The array of XML tags to parse.
+ * @param array $tags The array of XML tags to parse.
* @return array A PHP array containing the data.
*/
private function processTags($tags) {
$results = array();
foreach ($tags as $tag) {
$results[] = array(
'file' => $tag->getAttribute('file'),
'start' => $tag->getAttribute('start'),
'end' => $tag->getAttribute('end'),
);
}
return $results;
}
/**
* Reads the code coverage results from the cscover results file.
*
- * @param string The path to the code coverage file.
+ * @param string $cover_file The path to the code coverage file.
* @return array The code coverage results.
*/
public function readCoverage($cover_file) {
$cached = $this->getCachedResultsIfPossible($cover_file);
if ($cached !== null) {
return $cached;
}
$coverage_dom = new DOMDocument();
$coverage_dom->loadXML(Filesystem::readFile($cover_file));
$modified = $this->getPaths();
$files = array();
$reports = array();
$instrumented = array();
$executed = array();
$instrumented = $this->processTags(
$coverage_dom->getElementsByTagName('instrumented'));
$executed = $this->processTags(
$coverage_dom->getElementsByTagName('executed'));
foreach ($instrumented as $instrument) {
$absolute_file = $instrument['file'];
$relative_file = substr($absolute_file, strlen($this->projectRoot) + 1);
if (!in_array($relative_file, $files)) {
$files[] = $relative_file;
}
}
foreach ($files as $file) {
$absolute_file = Filesystem::resolvePath(
$this->projectRoot.DIRECTORY_SEPARATOR.$file);
// get total line count in file
$line_count = count(file($absolute_file));
$coverage = array();
for ($i = 0; $i < $line_count; $i++) {
$coverage[$i] = 'N';
}
foreach ($instrumented as $instrument) {
if ($instrument['file'] !== $absolute_file) {
continue;
}
for (
$i = $instrument['start'];
$i <= $instrument['end'];
$i++) {
$coverage[$i - 1] = 'U';
}
}
foreach ($executed as $execute) {
if ($execute['file'] !== $absolute_file) {
continue;
}
for (
$i = $execute['start'];
$i <= $execute['end'];
$i++) {
$coverage[$i - 1] = 'C';
}
}
$reports[$file] = implode($coverage);
}
$this->addCachedResults($cover_file, $reports);
return $reports;
}
}
diff --git a/src/unit/engine/PhpunitTestEngine.php b/src/unit/engine/PhpunitTestEngine.php
index 4cb89006..30cc84c3 100644
--- a/src/unit/engine/PhpunitTestEngine.php
+++ b/src/unit/engine/PhpunitTestEngine.php
@@ -1,280 +1,280 @@
<?php
/**
* PHPUnit wrapper.
*/
final class PhpunitTestEngine extends ArcanistUnitTestEngine {
private $configFile;
private $phpunitBinary = 'phpunit';
private $affectedTests;
private $projectRoot;
public function run() {
$this->projectRoot = $this->getWorkingCopy()->getProjectRoot();
$this->affectedTests = array();
foreach ($this->getPaths() as $path) {
$path = Filesystem::resolvePath($path, $this->projectRoot);
// TODO: add support for directories
// Users can call phpunit on the directory themselves
if (is_dir($path)) {
continue;
}
// Not sure if it would make sense to go further if
// it is not a .php file
if (substr($path, -4) != '.php') {
continue;
}
if (substr($path, -8) == 'Test.php') {
// Looks like a valid test file name.
$this->affectedTests[$path] = $path;
continue;
}
if ($test = $this->findTestFile($path)) {
$this->affectedTests[$path] = $test;
}
}
if (empty($this->affectedTests)) {
throw new ArcanistNoEffectException(pht('No tests to run.'));
}
$this->prepareConfigFile();
$futures = array();
$tmpfiles = array();
foreach ($this->affectedTests as $class_path => $test_path) {
if (!Filesystem::pathExists($test_path)) {
continue;
}
$xml_tmp = new TempFile();
$clover_tmp = null;
$clover = null;
if ($this->getEnableCoverage() !== false) {
$clover_tmp = new TempFile();
$clover = csprintf('--coverage-clover %s', $clover_tmp);
}
$config = $this->configFile ? csprintf('-c %s', $this->configFile) : null;
$stderr = '-d display_errors=stderr';
$futures[$test_path] = new ExecFuture('%C %C %C --log-junit %s %C %s',
$this->phpunitBinary, $config, $stderr, $xml_tmp, $clover, $test_path);
$tmpfiles[$test_path] = array(
'xml' => $xml_tmp,
'clover' => $clover_tmp,
);
}
$results = array();
$futures = id(new FutureIterator($futures))
->limit(4);
foreach ($futures as $test => $future) {
list($err, $stdout, $stderr) = $future->resolve();
$results[] = $this->parseTestResults(
$test,
$tmpfiles[$test]['xml'],
$tmpfiles[$test]['clover'],
$stderr);
}
return array_mergev($results);
}
/**
* Parse test results from phpunit json report.
*
* @param string $path Path to test
* @param string $json_tmp Path to phpunit json report
* @param string $clover_tmp Path to phpunit clover report
* @param string $stderr Data written to stderr
*
* @return array
*/
private function parseTestResults($path, $xml_tmp, $clover_tmp, $stderr) {
$test_results = Filesystem::readFile($xml_tmp);
return id(new ArcanistPhpunitTestResultParser())
->setEnableCoverage($this->getEnableCoverage())
->setProjectRoot($this->projectRoot)
->setCoverageFile($clover_tmp)
->setAffectedTests($this->affectedTests)
->setStderr($stderr)
->parseTestResults($path, $test_results);
}
/**
* Search for test cases for a given file in a large number of "reasonable"
* locations. See @{method:getSearchLocationsForTests} for specifics.
*
* TODO: Add support for finding tests in testsuite folders from
* phpunit.xml configuration.
*
- * @param string PHP file to locate test cases for.
+ * @param string $path PHP file to locate test cases for.
* @return string|null Path to test cases, or null.
*/
private function findTestFile($path) {
$root = $this->projectRoot;
$path = Filesystem::resolvePath($path, $root);
$file = basename($path);
$possible_files = array(
$file,
substr($file, 0, -4).'Test.php',
);
$search = self::getSearchLocationsForTests($path);
foreach ($search as $search_path) {
foreach ($possible_files as $possible_file) {
$full_path = $search_path.$possible_file;
if (!Filesystem::pathExists($full_path)) {
// If the file doesn't exist, it's clearly a miss.
continue;
}
if (!Filesystem::isDescendant($full_path, $root)) {
// Don't look above the project root.
continue;
}
if (0 == strcasecmp(Filesystem::resolvePath($full_path), $path)) {
// Don't return the original file.
continue;
}
return $full_path;
}
}
return null;
}
/**
* Get places to look for PHP Unit tests that cover a given file. For some
* file "/a/b/c/X.php", we look in the same directory:
*
* /a/b/c/
*
* We then look in all parent directories for a directory named "tests/"
* (or "Tests/"):
*
* /a/b/c/tests/
* /a/b/tests/
* /a/tests/
* /tests/
*
* We also try to replace each directory component with "tests/":
*
* /a/b/tests/
* /a/tests/c/
* /tests/b/c/
*
* We also try to add "tests/" at each directory level:
*
* /a/b/c/tests/
* /a/b/tests/c/
* /a/tests/b/c/
* /tests/a/b/c/
*
* This finds tests with a layout like:
*
* docs/
* src/
* tests/
*
* ...or similar. This list will be further pruned by the caller; it is
* intentionally filesystem-agnostic to be unit testable.
*
- * @param string PHP file to locate test cases for.
+ * @param string $path PHP file to locate test cases for.
* @return list<string> List of directories to search for tests in.
*/
public static function getSearchLocationsForTests($path) {
$file = basename($path);
$dir = dirname($path);
$test_dir_names = array('tests', 'Tests');
$try_directories = array();
// Try in the current directory.
$try_directories[] = array($dir);
// Try in a tests/ directory anywhere in the ancestry.
foreach (Filesystem::walkToRoot($dir) as $parent_dir) {
if ($parent_dir == '/') {
// We'll restore this later.
$parent_dir = '';
}
foreach ($test_dir_names as $test_dir_name) {
$try_directories[] = array($parent_dir, $test_dir_name);
}
}
// Try replacing each directory component with 'tests/'.
$parts = trim($dir, DIRECTORY_SEPARATOR);
$parts = explode(DIRECTORY_SEPARATOR, $parts);
foreach (array_reverse(array_keys($parts)) as $key) {
foreach ($test_dir_names as $test_dir_name) {
$try = $parts;
$try[$key] = $test_dir_name;
array_unshift($try, '');
$try_directories[] = $try;
}
}
// Try adding 'tests/' at each level.
foreach (array_reverse(array_keys($parts)) as $key) {
foreach ($test_dir_names as $test_dir_name) {
$try = $parts;
$try[$key] = $test_dir_name.DIRECTORY_SEPARATOR.$try[$key];
array_unshift($try, '');
$try_directories[] = $try;
}
}
$results = array();
foreach ($try_directories as $parts) {
$results[implode(DIRECTORY_SEPARATOR, $parts).DIRECTORY_SEPARATOR] = true;
}
return array_keys($results);
}
/**
* Tries to find and update phpunit configuration file based on
* `phpunit_config` option in `.arcconfig`.
*/
private function prepareConfigFile() {
$project_root = $this->projectRoot.DIRECTORY_SEPARATOR;
$config = $this->getConfigurationManager()->getConfigFromAnySource(
'phpunit_config');
if ($config) {
if (Filesystem::pathExists($project_root.$config)) {
$this->configFile = $project_root.$config;
} else {
throw new Exception(
pht(
'PHPUnit configuration file was not found in %s',
$project_root.$config));
}
}
$bin = $this->getConfigurationManager()->getConfigFromAnySource(
'unit.phpunit.binary');
if ($bin) {
if (Filesystem::binaryExists($bin)) {
$this->phpunitBinary = $bin;
} else {
$this->phpunitBinary = Filesystem::resolvePath($bin, $project_root);
}
}
}
}
diff --git a/src/unit/engine/XUnitTestEngine.php b/src/unit/engine/XUnitTestEngine.php
index d6a20464..9e4eb447 100644
--- a/src/unit/engine/XUnitTestEngine.php
+++ b/src/unit/engine/XUnitTestEngine.php
@@ -1,465 +1,465 @@
<?php
/**
* Uses xUnit (http://xunit.codeplex.com/) to test C# code.
*
* Assumes that when modifying a file with a path like `SomeAssembly/MyFile.cs`,
* that the test assembly that verifies the functionality of `SomeAssembly` is
* located at `SomeAssembly.Tests`.
*
* @concrete-extensible
*/
class XUnitTestEngine extends ArcanistUnitTestEngine {
protected $runtimeEngine;
protected $buildEngine;
protected $testEngine;
protected $projectRoot;
protected $xunitHintPath;
protected $discoveryRules;
/**
* This test engine supports running all tests.
*/
protected function supportsRunAllTests() {
return true;
}
/**
* Determines what executables and test paths to use. Between platforms this
* also changes whether the test engine is run under .NET or Mono. It also
* ensures that all of the required binaries are available for the tests to
* run successfully.
*
* @return void
*/
protected function loadEnvironment() {
$this->projectRoot = $this->getWorkingCopy()->getProjectRoot();
// Determine build engine.
if (Filesystem::binaryExists('msbuild')) {
$this->buildEngine = 'msbuild';
} else if (Filesystem::binaryExists('xbuild')) {
$this->buildEngine = 'xbuild';
} else {
throw new Exception(
pht(
'Unable to find %s or %s in %s!',
'msbuild',
'xbuild',
'PATH'));
}
// Determine runtime engine (.NET or Mono).
if (phutil_is_windows()) {
$this->runtimeEngine = '';
} else if (Filesystem::binaryExists('mono')) {
$this->runtimeEngine = Filesystem::resolveBinary('mono');
} else {
throw new Exception(
pht('Unable to find Mono and you are not on Windows!'));
}
// Read the discovery rules.
$this->discoveryRules =
$this->getConfigurationManager()->getConfigFromAnySource(
'unit.csharp.discovery');
if ($this->discoveryRules === null) {
throw new Exception(
pht(
'You must configure discovery rules to map C# files '.
'back to test projects (`%s` in %s).',
'unit.csharp.discovery',
'.arcconfig'));
}
// Determine xUnit test runner path.
if ($this->xunitHintPath === null) {
$this->xunitHintPath =
$this->getConfigurationManager()->getConfigFromAnySource(
'unit.csharp.xunit.binary');
}
$xunit = $this->projectRoot.DIRECTORY_SEPARATOR.$this->xunitHintPath;
if (file_exists($xunit) && $this->xunitHintPath !== null) {
$this->testEngine = Filesystem::resolvePath($xunit);
} else if (Filesystem::binaryExists('xunit.console.clr4.exe')) {
$this->testEngine = 'xunit.console.clr4.exe';
} else {
throw new Exception(
pht(
"Unable to locate xUnit console runner. Configure ".
"it with the `%s' option in %s.",
'unit.csharp.xunit.binary',
'.arcconfig'));
}
}
/**
* Main entry point for the test engine. Determines what assemblies to build
* and test based on the files that have changed.
*
* @return array Array of test results.
*/
public function run() {
$this->loadEnvironment();
if ($this->getRunAllTests()) {
$paths = id(new FileFinder($this->projectRoot))->find();
} else {
$paths = $this->getPaths();
}
return $this->runAllTests($this->mapPathsToResults($paths));
}
/**
* Applies the discovery rules to the set of paths specified.
*
- * @param array Array of paths.
+ * @param array $paths Array of paths.
* @return array Array of paths to test projects and assemblies.
*/
public function mapPathsToResults(array $paths) {
$results = array();
foreach ($this->discoveryRules as $regex => $targets) {
$regex = str_replace('/', '\\/', $regex);
foreach ($paths as $path) {
if (preg_match('/'.$regex.'/', $path) === 1) {
foreach ($targets as $target) {
// Index 0 is the test project (.csproj file)
// Index 1 is the output assembly (.dll file)
$project = preg_replace('/'.$regex.'/', $target[0], $path);
$project = $this->projectRoot.DIRECTORY_SEPARATOR.$project;
$assembly = preg_replace('/'.$regex.'/', $target[1], $path);
$assembly = $this->projectRoot.DIRECTORY_SEPARATOR.$assembly;
if (file_exists($project)) {
$project = Filesystem::resolvePath($project);
$assembly = Filesystem::resolvePath($assembly);
// Check to ensure uniqueness.
$exists = false;
foreach ($results as $existing) {
if ($existing['assembly'] === $assembly) {
$exists = true;
break;
}
}
if (!$exists) {
$results[] = array(
'project' => $project,
'assembly' => $assembly,
);
}
}
}
}
}
}
return $results;
}
/**
* Builds and runs the specified test assemblies.
*
- * @param array Array of paths to test project files.
+ * @param array $test_projects Array of paths to test project files.
* @return array Array of test results.
*/
public function runAllTests(array $test_projects) {
if (empty($test_projects)) {
return array();
}
$results = array();
$results[] = $this->generateProjects();
if ($this->resultsContainFailures($results)) {
return array_mergev($results);
}
$results[] = $this->buildProjects($test_projects);
if ($this->resultsContainFailures($results)) {
return array_mergev($results);
}
$results[] = $this->testAssemblies($test_projects);
return array_mergev($results);
}
/**
* Determine whether or not a current set of results contains any failures.
* This is needed since we build the assemblies as part of the unit tests, but
* we can't run any of the unit tests if the build fails.
*
- * @param array Array of results to check.
+ * @param array $results Array of results to check.
* @return bool If there are any failures in the results.
*/
private function resultsContainFailures(array $results) {
$results = array_mergev($results);
foreach ($results as $result) {
if ($result->getResult() != ArcanistUnitTestResult::RESULT_PASS) {
return true;
}
}
return false;
}
/**
* If the `Build` directory exists, we assume that this is a multi-platform
* project that requires generation of C# project files. Because we want to
* test that the generation and subsequent build is whole, we need to
* regenerate any projects in case the developer has added files through an
* IDE and then forgotten to add them to the respective `.definitions` file.
* By regenerating the projects we ensure that any missing definition entries
* will cause the build to fail.
*
* @return array Array of test results.
*/
private function generateProjects() {
// No "Build" directory; so skip generation of projects.
if (!is_dir(Filesystem::resolvePath($this->projectRoot.'/Build'))) {
return array();
}
// No "Protobuild.exe" file; so skip generation of projects.
if (!is_file(Filesystem::resolvePath(
$this->projectRoot.'/Protobuild.exe'))) {
return array();
}
// Work out what platform the user is building for already.
$platform = phutil_is_windows() ? 'Windows' : 'Linux';
$files = Filesystem::listDirectory($this->projectRoot);
foreach ($files as $file) {
if (strtolower(substr($file, -4)) == '.sln') {
$parts = explode('.', $file);
$platform = $parts[count($parts) - 2];
break;
}
}
$regenerate_start = microtime(true);
$regenerate_future = new ExecFuture(
'%C Protobuild.exe --resync %s',
$this->runtimeEngine,
$platform);
$regenerate_future->setCWD(Filesystem::resolvePath(
$this->projectRoot));
$results = array();
$result = new ArcanistUnitTestResult();
$result->setName(pht('(regenerate projects for %s)', $platform));
try {
$regenerate_future->resolvex();
$result->setResult(ArcanistUnitTestResult::RESULT_PASS);
} catch (CommandException $exc) {
if ($exc->getError() > 1) {
throw $exc;
}
$result->setResult(ArcanistUnitTestResult::RESULT_FAIL);
$result->setUserData($exc->getStdout());
}
$result->setDuration(microtime(true) - $regenerate_start);
$results[] = $result;
return $results;
}
/**
* Build the projects relevant for the specified test assemblies and return
* the results of the builds as test results. This build also passes the
* "SkipTestsOnBuild" parameter when building the projects, so that MSBuild
* conditionals can be used to prevent any tests running as part of the
* build itself (since the unit tester is about to run each of the tests
* individually).
*
- * @param array Array of test assemblies.
+ * @param array $test_assemblies Array of test assemblies.
* @return array Array of test results.
*/
private function buildProjects(array $test_assemblies) {
$build_futures = array();
$build_failed = false;
$build_start = microtime(true);
$results = array();
foreach ($test_assemblies as $test_assembly) {
$build_future = new ExecFuture(
'%C %s',
$this->buildEngine,
'/p:SkipTestsOnBuild=True');
$build_future->setCWD(Filesystem::resolvePath(
dirname($test_assembly['project'])));
$build_futures[$test_assembly['project']] = $build_future;
}
$iterator = id(new FutureIterator($build_futures))->limit(1);
foreach ($iterator as $test_assembly => $future) {
$result = new ArcanistUnitTestResult();
$result->setName('(build) '.$test_assembly);
try {
$future->resolvex();
$result->setResult(ArcanistUnitTestResult::RESULT_PASS);
} catch (CommandException $exc) {
if ($exc->getError() > 1) {
throw $exc;
}
$result->setResult(ArcanistUnitTestResult::RESULT_FAIL);
$result->setUserData($exc->getStdout());
$build_failed = true;
}
$result->setDuration(microtime(true) - $build_start);
$results[] = $result;
}
return $results;
}
/**
* Build the future for running a unit test. This can be overridden to enable
* support for code coverage via another tool.
*
- * @param string Name of the test assembly.
+ * @param string $test_assembly Name of the test assembly.
* @return array The future, output filename and coverage filename
* stored in an array.
*/
protected function buildTestFuture($test_assembly) {
// FIXME: Can't use TempFile here as xUnit doesn't like
// UNIX-style full paths. It sees the leading / as the
// start of an option flag, even when quoted.
$xunit_temp = Filesystem::readRandomCharacters(10).'.results.xml';
if (file_exists($xunit_temp)) {
unlink($xunit_temp);
}
$future = new ExecFuture(
'%C %s /xml %s',
trim($this->runtimeEngine.' '.$this->testEngine),
$test_assembly,
$xunit_temp);
$folder = Filesystem::resolvePath($this->projectRoot);
$future->setCWD($folder);
$combined = $folder.'/'.$xunit_temp;
if (phutil_is_windows()) {
$combined = $folder.'\\'.$xunit_temp;
}
return array($future, $combined, null);
}
/**
* Run the xUnit test runner on each of the assemblies and parse the
* resulting XML.
*
- * @param array Array of test assemblies.
+ * @param array $test_assemblies Array of test assemblies.
* @return array Array of test results.
*/
private function testAssemblies(array $test_assemblies) {
$results = array();
// Build the futures for running the tests.
$futures = array();
$outputs = array();
$coverages = array();
foreach ($test_assemblies as $test_assembly) {
list($future_r, $xunit_temp, $coverage) =
$this->buildTestFuture($test_assembly['assembly']);
$futures[$test_assembly['assembly']] = $future_r;
$outputs[$test_assembly['assembly']] = $xunit_temp;
$coverages[$test_assembly['assembly']] = $coverage;
}
// Run all of the tests.
$futures = id(new FutureIterator($futures))
->limit(8);
foreach ($futures as $test_assembly => $future) {
list($err, $stdout, $stderr) = $future->resolve();
if (file_exists($outputs[$test_assembly])) {
$result = $this->parseTestResult(
$outputs[$test_assembly],
$coverages[$test_assembly]);
$results[] = $result;
unlink($outputs[$test_assembly]);
} else {
// FIXME: There's a bug in Mono which causes a segmentation fault
// when xUnit.NET runs; this causes the XML file to not appear
// (depending on when the segmentation fault occurs). See
// https://bugzilla.xamarin.com/show_bug.cgi?id=16379
// for more information.
// Since it's not possible for the user to correct this error, we
// ignore the fact the tests didn't run here.
}
}
return array_mergev($results);
}
/**
* Returns null for this implementation as xUnit does not support code
* coverage directly. Override this method in another class to provide code
* coverage information (also see @{class:CSharpToolsUnitEngine}).
*
- * @param string The name of the coverage file if one was provided by
- * `buildTestFuture`.
+ * @param string $coverage The name of the coverage file if one was
+ * provided by `buildTestFuture`.
* @return array Code coverage results, or null.
*/
protected function parseCoverageResult($coverage) {
return null;
}
/**
* Parses the test results from xUnit.
*
- * @param string The name of the xUnit results file.
- * @param string The name of the coverage file if one was provided by
- * `buildTestFuture`. This is passed through to
+ * @param string $xunit_tmp The name of the xUnit results file.
+ * @param string $coverage The name of the coverage file if one was
+ * provided by `buildTestFuture`. This is passed through to
* `parseCoverageResult`.
* @return array Test results.
*/
private function parseTestResult($xunit_tmp, $coverage) {
$xunit_dom = new DOMDocument();
$xunit_dom->loadXML(Filesystem::readFile($xunit_tmp));
$results = array();
$tests = $xunit_dom->getElementsByTagName('test');
foreach ($tests as $test) {
$name = $test->getAttribute('name');
$time = $test->getAttribute('time');
$status = ArcanistUnitTestResult::RESULT_UNSOUND;
switch ($test->getAttribute('result')) {
case 'Pass':
$status = ArcanistUnitTestResult::RESULT_PASS;
break;
case 'Fail':
$status = ArcanistUnitTestResult::RESULT_FAIL;
break;
case 'Skip':
$status = ArcanistUnitTestResult::RESULT_SKIP;
break;
}
$userdata = '';
$reason = $test->getElementsByTagName('reason');
$failure = $test->getElementsByTagName('failure');
if ($reason->length > 0 || $failure->length > 0) {
$node = ($reason->length > 0) ? $reason : $failure;
$message = $node->item(0)->getElementsByTagName('message');
if ($message->length > 0) {
$userdata = $message->item(0)->nodeValue;
}
$stacktrace = $node->item(0)->getElementsByTagName('stack-trace');
if ($stacktrace->length > 0) {
$userdata .= "\n".$stacktrace->item(0)->nodeValue;
}
}
$result = new ArcanistUnitTestResult();
$result->setName($name);
$result->setResult($status);
$result->setDuration($time);
$result->setUserData($userdata);
if ($coverage != null) {
$result->setCoverage($this->parseCoverageResult($coverage));
}
$results[] = $result;
}
return $results;
}
}
diff --git a/src/unit/engine/phutil/PhutilTestCase.php b/src/unit/engine/phutil/PhutilTestCase.php
index 81062ff2..e6a06ccd 100644
--- a/src/unit/engine/phutil/PhutilTestCase.php
+++ b/src/unit/engine/phutil/PhutilTestCase.php
@@ -1,926 +1,932 @@
<?php
/**
* Base test case for the very simple libphutil test framework.
*
* @task assert Making Test Assertions
* @task exceptions Exception Handling
* @task hook Hooks for Setup and Teardown
* @task internal Internals
*/
abstract class PhutilTestCase extends Phobject {
private $assertions = 0;
private $runningTest;
private $testStartTime;
private $results = array();
private $enableCoverage;
private $coverage = array();
private $workingCopy;
private $paths;
private $renderer;
private static $executables = array();
/* -( Making Test Assertions )--------------------------------------------- */
/**
* Assert that a value is `false`, strictly. The test fails if it is not.
*
- * @param wild The empirically derived value, generated by executing the
- * test.
- * @param string A human-readable description of what these values represent,
- * and particularly of what a discrepancy means.
+ * @param wild $result The empirically derived value, generated by
+ * executing the test.
+ * @param string $message (optional) A human-readable description of what
+ * these values represent, and particularly of what a
+ * discrepancy means.
*
* @return void
* @task assert
*/
final protected function assertFalse($result, $message = null) {
if ($result === false) {
$this->assertions++;
return;
}
$this->failAssertionWithExpectedValue('false', $result, $message);
}
/**
* Assert that a value is `true`, strictly. The test fails if it is not.
*
- * @param wild The empirically derived value, generated by executing the
- * test.
- * @param string A human-readable description of what these values represent,
- * and particularly of what a discrepancy means.
+ * @param wild $result The empirically derived value, generated by
+ * executing the test.
+ * @param string $message (optional) A human-readable description of what
+ * these values represent, and particularly of what a
+ * discrepancy means.
*
* @return void
* @task assert
*/
final protected function assertTrue($result, $message = null) {
if ($result === true) {
$this->assertions++;
return;
}
$this->failAssertionWithExpectedValue('true', $result, $message);
}
/**
* Assert that two values are equal, strictly. The test fails if they are not.
*
* NOTE: This method uses PHP's strict equality test operator (`===`) to
* compare values. This means values and types must be equal, key order must
* be identical in arrays, and objects must be referentially identical.
*
- * @param wild The theoretically expected value, generated by careful
- * reasoning about the properties of the system.
- * @param wild The empirically derived value, generated by executing the
- * test.
- * @param string A human-readable description of what these values represent,
- * and particularly of what a discrepancy means.
+ * @param wild $expect The theoretically expected value, generated by
+ * careful reasoning about the properties of the system.
+ * @param wild $result The empirically derived value, generated by
+ * executing the test.
+ * @param string $message (optional) A human-readable description of what
+ * these values represent, and particularly of what a
+ * discrepancy means.
*
* @return void
* @task assert
*/
final protected function assertEqual($expect, $result, $message = null) {
if ($expect === $result) {
$this->assertions++;
return;
}
$expect = PhutilReadableSerializer::printableValue($expect);
$result = PhutilReadableSerializer::printableValue($result);
$caller = self::getCallerInfo();
$file = $caller['file'];
$line = $caller['line'];
if ($message !== null) {
$output = pht(
'Assertion failed, expected values to be equal (at %s:%d): %s',
$file,
$line,
$message);
} else {
$output = pht(
'Assertion failed, expected values to be equal (at %s:%d).',
$file,
$line);
}
$output .= "\n";
if (strpos($expect, "\n") === false && strpos($result, "\n") === false) {
$output .= pht("Expected: %s\n Actual: %s", $expect, $result);
} else {
$output .= pht(
"Expected vs Actual Output Diff\n%s",
ArcanistDiffUtils::renderDifferences(
$expect,
$result,
$lines = 0xFFFF));
}
$this->failTest($output);
throw new PhutilTestTerminatedException($output);
}
/**
* Assert an unconditional failure. This is just a convenience method that
* better indicates intent than using dummy values with assertEqual(). This
* causes test failure.
*
- * @param string Human-readable description of the reason for test failure.
+ * @param string $message Human-readable description of the reason for
+ * test failure.
* @return void
* @task assert
*/
final protected function assertFailure($message) {
$this->failTest($message);
throw new PhutilTestTerminatedException($message);
}
/**
* End this test by asserting that the test should be skipped for some
* reason.
*
- * @param string Reason for skipping this test.
+ * @param string $message Reason for skipping this test.
* @return void
* @task assert
*/
final protected function assertSkipped($message) {
$this->skipTest($message);
throw new PhutilTestSkippedException($message);
}
final protected function assertCaught(
$expect,
$actual,
$message = null) {
if ($message !== null) {
$message = phutil_string_cast($message);
}
if ($actual === null) {
// This is okay: no exception.
} else if ($actual instanceof Exception) {
// This is also okay.
} else if ($actual instanceof Throwable) {
// And this is okay too.
} else {
// Anything else is no good.
if ($message !== null) {
$output = pht(
'Call to "assertCaught(..., <junk>, ...)" for test case "%s" '.
'passed bad value for test result. Expected null, Exception, '.
'or Throwable; got: %s.',
$message,
phutil_describe_type($actual));
} else {
$output = pht(
'Call to "assertCaught(..., <junk>, ...)" passed bad value for '.
'test result. Expected null, Exception, or Throwable; got: %s.',
phutil_describe_type($actual));
}
$this->failTest($output);
throw new PhutilTestTerminatedException($output);
}
$expect_list = null;
if ($expect === false) {
$expect_list = array();
} else if ($expect === true) {
$expect_list = array(
'Exception',
'Throwable',
);
} else if (is_string($expect) || is_array($expect)) {
$list = (array)$expect;
$items_ok = true;
foreach ($list as $key => $item) {
if (!phutil_nonempty_stringlike($item)) {
$items_ok = false;
break;
}
$list[$key] = phutil_string_cast($item);
}
if ($items_ok) {
$expect_list = $list;
}
}
if ($expect_list === null) {
if ($message !== null) {
$output = pht(
'Call to "assertCaught(<junk>, ...)" for test case "%s" '.
'passed bad expected value. Expected bool, class name as a string, '.
'or a list of class names. Got: %s.',
$message,
phutil_describe_type($expect));
} else {
$output = pht(
'Call to "assertCaught(<junk>, ...)" passed bad expected value. '.
'expected result. Expected null, Exception, or Throwable; got: %s.',
phutil_describe_type($expect));
}
$this->failTest($output);
throw new PhutilTestTerminatedException($output);
}
if ($actual === null) {
$is_match = !$expect_list;
} else {
$is_match = false;
foreach ($expect_list as $exception_class) {
if ($actual instanceof $exception_class) {
$is_match = true;
break;
}
}
}
if ($is_match) {
$this->assertions++;
return;
}
$caller = self::getCallerInfo();
$file = $caller['file'];
$line = $caller['line'];
$output = array();
if ($message !== null) {
$output[] = pht(
'Assertion of caught exception failed (at %s:%d in test case "%s").',
$file,
$line,
$message);
} else {
$output[] = pht(
'Assertion of caught exception failed (at %s:%d).',
$file,
$line);
}
if ($actual === null) {
$output[] = pht('Expected any exception, got no exception.');
} else if (!$expect_list) {
$output[] = pht(
'Expected no exception, got exception of class "%s".',
get_class($actual));
} else {
$expected_classes = implode(', ', $expect_list);
$output[] = pht(
'Expected exception (in class(es): %s), got exception of class "%s".',
$expected_classes,
get_class($actual));
}
$output = implode("\n\n", $output);
$this->failTest($output);
throw new PhutilTestTerminatedException($output);
}
/* -( Exception Handling )------------------------------------------------- */
/**
* This simplest way to assert exceptions are thrown.
*
- * @param exception The expected exception.
- * @param callable The thing which throws the exception.
+ * @param exception $expected_exception_class The expected exception.
+ * @param callable $callable The thing which throws the exception.
*
* @return void
* @task exceptions
*/
final protected function assertException(
$expected_exception_class,
$callable) {
$this->tryTestCases(
array('assertException' => array()),
array(false),
$callable,
$expected_exception_class);
}
/**
* Straightforward method for writing unit tests which check if some block of
* code throws an exception. For example, this allows you to test the
* exception behavior of ##is_a_fruit()## on various inputs:
*
* public function testFruit() {
* $this->tryTestCases(
* array(
* 'apple is a fruit' => new Apple(),
* 'rock is not a fruit' => new Rock(),
* ),
* array(
* true,
* false,
* ),
* array($this, 'tryIsAFruit'),
* 'NotAFruitException');
* }
*
* protected function tryIsAFruit($input) {
* is_a_fruit($input);
* }
*
- * @param map Map of test case labels to test case inputs.
- * @param list List of expected results, true to indicate that the case
- * is expected to succeed and false to indicate that the case
- * is expected to throw.
- * @param callable Callback to invoke for each test case.
- * @param string Optional exception class to catch, defaults to
- * 'Exception'.
+ * @param map $inputs Map of test case labels to test case inputs.
+ * @param list $expect List of expected results, true to indicate that
+ * the case is expected to succeed and false to indicate
+ * that the case is expected to throw.
+ * @param callable $callable Callback to invoke for each test case.
+ * @param string $exception_class (optional) Exception class to catch,
+ * defaults to 'Exception'.
* @return void
* @task exceptions
*/
final protected function tryTestCases(
array $inputs,
array $expect,
$callable,
$exception_class = 'Exception') {
if (count($inputs) !== count($expect)) {
$this->assertFailure(
pht('Input and expectations must have the same number of values.'));
}
$labels = array_keys($inputs);
$inputs = array_values($inputs);
$expecting = array_values($expect);
foreach ($inputs as $idx => $input) {
$expect = $expecting[$idx];
$label = $labels[$idx];
$caught = null;
try {
call_user_func($callable, $input);
} catch (Exception $ex) {
if ($ex instanceof PhutilTestTerminatedException) {
throw $ex;
}
if (!($ex instanceof $exception_class)) {
throw $ex;
}
$caught = $ex;
}
$actual = !($caught instanceof Exception);
if ($expect === $actual) {
if ($expect) {
$message = pht("Test case '%s' did not throw, as expected.", $label);
} else {
$message = pht("Test case '%s' threw, as expected.", $label);
}
} else {
if ($expect) {
$message = pht(
"Test case '%s' was expected to succeed, but it ".
"raised an exception of class %s with message: %s",
$label,
get_class($ex),
$ex->getMessage());
} else {
$message = pht(
"Test case '%s' was expected to raise an ".
"exception, but it did not throw anything.",
$label);
}
}
$this->assertEqual($expect, $actual, $message);
}
}
/**
* Convenience wrapper around @{method:tryTestCases} for cases where your
* inputs are scalar. For example:
*
* public function testFruit() {
* $this->tryTestCaseMap(
* array(
* 'apple' => true,
* 'rock' => false,
* ),
* array($this, 'tryIsAFruit'),
* 'NotAFruitException');
* }
*
* protected function tryIsAFruit($input) {
* is_a_fruit($input);
* }
*
* For cases where your inputs are not scalar, use @{method:tryTestCases}.
*
- * @param map Map of scalar test inputs to expected success (true
+ * @param map $map Map of scalar test inputs to expected success (true
* expects success, false expects an exception).
- * @param callable Callback to invoke for each test case.
- * @param string Optional exception class to catch, defaults to
- * 'Exception'.
+ * @param callable $callable Callback to invoke for each test case.
+ * @param string $exception_class (optional) Exception class to catch,
+ * defaults to 'Exception'.
* @return void
* @task exceptions
*/
final protected function tryTestCaseMap(
array $map,
$callable,
$exception_class = 'Exception') {
$this->tryTestCases(
array_fuse(array_keys($map)),
array_values($map),
$callable,
$exception_class);
}
/* -( Hooks for Setup and Teardown )--------------------------------------- */
/**
* This hook is invoked once, before any tests in this class are run. It
* gives you an opportunity to perform setup steps for the entire class.
*
* @return void
* @task hook
*/
protected function willRunTests() {
return;
}
/**
* This hook is invoked once, after any tests in this class are run. It gives
* you an opportunity to perform teardown steps for the entire class.
*
* @return void
* @task hook
*/
protected function didRunTests() {
return;
}
/**
* This hook is invoked once per test, before the test method is invoked.
*
- * @param string Method name of the test which will be invoked.
+ * @param string $test_method_name Method name of the test which will be
+ * invoked.
* @return void
* @task hook
*/
protected function willRunOneTest($test_method_name) {
return;
}
/**
* This hook is invoked once per test, after the test method is invoked.
*
- * @param string Method name of the test which was invoked.
+ * @param string $test_method_name Method name of the test which was invoked.
* @return void
* @task hook
*/
protected function didRunOneTest($test_method_name) {
return;
}
/**
* This hook is invoked once, before any test cases execute. It gives you
* an opportunity to perform setup steps for the entire suite of test cases.
*
- * @param list<PhutilTestCase> List of test cases to be run.
+ * @param list<PhutilTestCase> $test_cases List of test cases to be run.
* @return void
* @task hook
*/
public function willRunTestCases(array $test_cases) {
return;
}
/**
* This hook is invoked once, after all test cases execute.
*
- * @param list<PhutilTestCase> List of test cases that ran.
+ * @param list<PhutilTestCase> $test_cases List of test cases that ran.
* @return void
* @task hook
*/
public function didRunTestCases(array $test_cases) {
return;
}
/* -( Internals )---------------------------------------------------------- */
/**
* Construct a new test case. This method is ##final##, use willRunTests() to
* provide test-wide setup logic.
*
* @task internal
*/
final public function __construct() {}
/**
* Mark the currently-running test as a failure.
*
- * @param string Human-readable description of problems.
+ * @param string $reason Human-readable description of problems.
* @return void
*
* @task internal
*/
private function failTest($reason) {
$this->resultTest(ArcanistUnitTestResult::RESULT_FAIL, $reason);
}
/**
* This was a triumph. I'm making a note here: HUGE SUCCESS.
*
- * @param string Human-readable overstatement of satisfaction.
+ * @param string $reason Human-readable overstatement of satisfaction.
* @return void
*
* @task internal
*/
private function passTest($reason) {
$this->resultTest(ArcanistUnitTestResult::RESULT_PASS, $reason);
}
/**
* Mark the current running test as skipped.
*
- * @param string Description for why this test was skipped.
+ * @param string $reason Description for why this test was skipped.
* @return void
* @task internal
*/
private function skipTest($reason) {
$this->resultTest(ArcanistUnitTestResult::RESULT_SKIP, $reason);
}
private function resultTest($test_result, $reason) {
$coverage = $this->endCoverage();
$result = new ArcanistUnitTestResult();
$result->setCoverage($coverage);
$result->setNamespace(get_class($this));
$result->setName($this->runningTest);
$result->setLink($this->getLink($this->runningTest));
$result->setResult($test_result);
$result->setDuration(microtime(true) - $this->testStartTime);
$result->setUserData($reason);
$this->results[] = $result;
if ($this->renderer) {
echo $this->renderer->renderUnitResult($result);
}
}
/**
* Execute the tests in this test case. You should not call this directly;
* use @{class:PhutilUnitTestEngine} to orchestrate test execution.
*
* @return void
* @task internal
*/
final public function run() {
$this->results = array();
$reflection = new ReflectionClass($this);
$methods = $reflection->getMethods();
// Try to ensure that poorly-written tests which depend on execution order
// (and are thus not properly isolated) will fail.
shuffle($methods);
$this->willRunTests();
foreach ($methods as $method) {
$name = $method->getName();
if (preg_match('/^test/', $name)) {
$this->runningTest = $name;
$this->assertions = 0;
$this->testStartTime = microtime(true);
try {
$this->willRunOneTest($name);
$this->beginCoverage();
$exceptions = array();
try {
call_user_func_array(
array($this, $name),
array());
$this->passTest(
pht(
'%s assertion(s) passed.',
new PhutilNumber($this->assertions)));
} catch (Exception $ex) {
$exceptions['Execution'] = $ex;
}
try {
$this->didRunOneTest($name);
} catch (Exception $ex) {
$exceptions['Shutdown'] = $ex;
}
if ($exceptions) {
if (count($exceptions) == 1) {
throw head($exceptions);
} else {
throw new PhutilAggregateException(
pht('Multiple exceptions were raised during test execution.'),
$exceptions);
}
}
if (!$this->assertions) {
$this->failTest(
pht(
'This test case made no assertions. Test cases must make at '.
'least one assertion.'));
}
} catch (PhutilTestTerminatedException $ex) {
// Continue with the next test.
} catch (PhutilTestSkippedException $ex) {
// Continue with the next test.
} catch (Exception $ex) {
$ex_class = get_class($ex);
$ex_message = $ex->getMessage();
$ex_trace = $ex->getTraceAsString();
$message = sprintf(
"%s (%s): %s\n%s",
pht('EXCEPTION'),
$ex_class,
$ex_message,
$ex_trace);
$this->failTest($message);
}
}
}
$this->didRunTests();
return $this->results;
}
final public function setEnableCoverage($enable_coverage) {
$this->enableCoverage = $enable_coverage;
return $this;
}
/**
* @phutil-external-symbol function xdebug_start_code_coverage
*/
private function beginCoverage() {
if (!$this->enableCoverage) {
return;
}
$this->assertCoverageAvailable();
xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE);
}
/**
* @phutil-external-symbol function xdebug_get_code_coverage
* @phutil-external-symbol function xdebug_stop_code_coverage
*/
private function endCoverage() {
if (!$this->enableCoverage) {
return;
}
$result = xdebug_get_code_coverage();
xdebug_stop_code_coverage($cleanup = false);
$coverage = array();
foreach ($result as $file => $report) {
$project_root = $this->getProjectRoot();
if (strncmp($file, $project_root, strlen($project_root))) {
continue;
}
$max = max(array_keys($report));
$str = '';
for ($ii = 1; $ii <= $max; $ii++) {
$c = null;
if (isset($report[$ii])) {
$c = $report[$ii];
}
if ($c === -1) {
$str .= 'U'; // Un-covered.
} else if ($c === -2) {
// TODO: This indicates "unreachable", but it flags the closing braces
// of functions which end in "return", which is super ridiculous. Just
// ignore it for now.
//
// See http://bugs.xdebug.org/view.php?id=1041
$str .= 'N'; // Not executable.
} else if ($c === 1) {
$str .= 'C'; // Covered.
} else {
$str .= 'N'; // Not executable.
}
}
$coverage[substr($file, strlen($project_root) + 1)] = $str;
}
// Only keep coverage information for files modified by the change. In
// the case of --everything, we won't have paths, so just return all the
// coverage data.
if ($this->paths) {
$coverage = array_select_keys($coverage, $this->paths);
}
return $coverage;
}
private function assertCoverageAvailable() {
if (!function_exists('xdebug_start_code_coverage')) {
throw new Exception(
pht("You've enabled code coverage but XDebug is not installed."));
}
}
final public function getWorkingCopy() {
return $this->workingCopy;
}
final public function setWorkingCopy(
ArcanistWorkingCopyIdentity $working_copy) {
$this->workingCopy = $working_copy;
return $this;
}
final public function getProjectRoot() {
$working_copy = $this->getWorkingCopy();
if (!$working_copy) {
throw new PhutilInvalidStateException('setWorkingCopy');
}
return $working_copy->getProjectRoot();
}
final public function setPaths(array $paths) {
$this->paths = $paths;
return $this;
}
final protected function getLink($method) {
$base_uri = $this
->getWorkingCopy()
->getProjectConfig('phabricator.uri');
$uri = id(new PhutilURI($base_uri))
->setPath("/diffusion/symbol/{$method}/")
->setQueryParam('context', get_class($this))
->setQueryParam('jump', 'true')
->setQueryParam('lang', 'php');
return (string)$uri;
}
final public function setRenderer(ArcanistUnitRenderer $renderer) {
$this->renderer = $renderer;
return $this;
}
/**
* Returns info about the caller function.
*
* @return map
*/
private static function getCallerInfo() {
$callee = array();
$caller = array();
$seen = false;
foreach (array_slice(debug_backtrace(), 1) as $location) {
$function = idx($location, 'function');
if (!$seen && preg_match('/^assert[A-Z]/', $function)) {
$seen = true;
$caller = $location;
} else if ($seen && !preg_match('/^assert[A-Z]/', $function)) {
$callee = $location;
break;
}
}
return array(
'file' => basename(idx($caller, 'file')),
'line' => idx($caller, 'line'),
'function' => idx($callee, 'function'),
'class' => idx($callee, 'class'),
'object' => idx($caller, 'object'),
'type' => idx($callee, 'type'),
'args' => idx($caller, 'args'),
);
}
/**
* Fail an assertion which checks that some result is equal to a specific
* value, like 'true' or 'false'. This prints a readable error message and
* fails the current test.
*
* This method throws and does not return.
*
- * @param string Human readable description of the expected value.
- * @param string The actual value.
- * @param string|null Optional assertion message.
+ * @param string $expect_description Human readable description of the
+ * expected value.
+ * @param string $actual_result The actual value.
+ * @param string|null $message Optional assertion message.
* @return void
* @task internal
*/
private function failAssertionWithExpectedValue(
$expect_description,
$actual_result,
$message) {
$caller = self::getCallerInfo();
$file = $caller['file'];
$line = $caller['line'];
if ($message !== null) {
$description = pht(
"Assertion failed, expected '%s' (at %s:%d): %s",
$expect_description,
$file,
$line,
$message);
} else {
$description = pht(
"Assertion failed, expected '%s' (at %s:%d).",
$expect_description,
$file,
$line);
}
$actual_result = PhutilReadableSerializer::printableValue($actual_result);
$header = pht('ACTUAL VALUE');
$output = $description."\n\n".$header."\n".$actual_result;
$this->failTest($output);
throw new PhutilTestTerminatedException($output);
}
final protected function assertExecutable($binary) {
if (!isset(self::$executables[$binary])) {
switch ($binary) {
case 'xhpast':
$ok = true;
if (!PhutilXHPASTBinary::isAvailable()) {
try {
PhutilXHPASTBinary::build();
} catch (Exception $ex) {
$ok = false;
}
}
break;
default:
$ok = Filesystem::binaryExists($binary);
break;
}
self::$executables[$binary] = $ok;
}
if (!self::$executables[$binary]) {
$this->assertSkipped(
pht('Required executable "%s" is not available.', $binary));
}
}
final protected function getSupportExecutable($executable) {
$root = dirname(phutil_get_library_root('arcanist'));
return $root.'/support/unit/'.$executable.'.php';
}
}
diff --git a/src/unit/parser/ArcanistTestResultParser.php b/src/unit/parser/ArcanistTestResultParser.php
index 9a4c41fc..2b43fc3d 100644
--- a/src/unit/parser/ArcanistTestResultParser.php
+++ b/src/unit/parser/ArcanistTestResultParser.php
@@ -1,49 +1,49 @@
<?php
/**
* Abstract base class for test result parsers.
*/
abstract class ArcanistTestResultParser extends Phobject {
protected $enableCoverage;
protected $projectRoot;
protected $coverageFile;
protected $stderr;
protected $affectedTests;
public function setEnableCoverage($enable_coverage) {
$this->enableCoverage = $enable_coverage;
return $this;
}
public function setProjectRoot($project_root) {
$this->projectRoot = $project_root;
return $this;
}
public function setCoverageFile($coverage_file) {
$this->coverageFile = $coverage_file;
return $this;
}
public function setAffectedTests($affected_tests) {
$this->affectedTests = $affected_tests;
return $this;
}
public function setStderr($stderr) {
$this->stderr = $stderr;
return $this;
}
/**
* Parse test results from provided input and return an array of
* @{class:ArcanistUnitTestResult}.
*
- * @param string Path to test.
- * @param string String containing test results.
+ * @param string $path Path to test.
+ * @param string $test_results String containing test results.
* @return array
*/
abstract public function parseTestResults($path, $test_results);
}
diff --git a/src/upload/ArcanistFileDataRef.php b/src/upload/ArcanistFileDataRef.php
index 99ed03d9..c1aa4d56 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 Filename.
+ * @param string $name Filename.
* @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 Blob of file data.
+ * @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 on disk to a file containing data to upload.
+ * @param string $path Path on disk to a file containing data to upload.
* @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 timestamp to retain the file until.
+ * @param int $epoch Epoch timestamp to retain the file until.
* @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 2af3ae25..cc4217be 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 data to upload.
- * @param null|string Optional key to use to identify this file.
+ * @param ArcanistFileDataRef $file File data to upload.
+ * @param null|string $key (optional) Key to use to identify this file.
* @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 24611c14..4e7db69a 100644
--- a/src/utils/AbstractDirectedGraph.php
+++ b/src/utils/AbstractDirectedGraph.php
@@ -1,337 +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 A list of nodes.
+ * @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 A map of nodes to the nodes reachable along their edges.
+ * @param dict $nodes A map of nodes to the nodes reachable along their
+ * edges
* @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
* @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 The node to walk from, looking for graph cycles.
+ * @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 The node to walk from.
- * @param list Previously visited nodes.
+ * @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/CaseInsensitiveArray.php b/src/utils/CaseInsensitiveArray.php
index 5f8cae53..66e43c5f 100644
--- a/src/utils/CaseInsensitiveArray.php
+++ b/src/utils/CaseInsensitiveArray.php
@@ -1,121 +1,121 @@
<?php
/**
* A case-insensitive associative array.
*
* This class represents an associative array in which the keys are considered
* to be case insensitive. This means that `$array['key']` and `$array['KEY']`
* will refer to the same array element.
*
* ```lang=php
* $array = new CaseInsensitiveArray();
*
* $array['key'] = 'value';
* echo $array['KEY']; // 'value'
*
* $array['kEy'] = 'foobar';
* var_dump($array->toArray()); // array('key' => 'foobar')
* ```
*
* Note that it is not possible to reuse case variants of a key. That is, if
* the array contains key `xyz` then it is not possible to use any of the
* following case variants as an array key: `xyZ`, `xYz`, `xYZ`, `Xyz`, `XyZ`,
* `XYz`, `XYZ`. In order to use a case variant as a key, it is necessary to
* first unset the original case variant.
*
* ```lang=php
* $array = new CaseInsensitiveArray(array('key' => 'foo', 'KEY' => 'bar'));
* var_dump($array->toArray()); // array('key' => 'bar')
*
* $array['KEY'] = 'baz';
* var_dump($array->toArray()); // array('key' => 'baz')
*
* unset($array['key']);
* $array['KEY'] = 'baz';
* var_dump($array->toArray()); // array('KEY' => 'baz')
* ```
*/
final class CaseInsensitiveArray extends PhutilArray {
/**
* Mapping between original and case-invariant keys.
*
* All keys in the parent `PhutilArray` are indexed using the case-invariant
* key rather than the original key.
*
* @var map<string, string>
*/
private $keys = array();
/**
* Construct a new array object.
*
- * @param array The input array.
+ * @param array $data (optional) The input array.
*/
public function __construct(array $data = array()) {
foreach ($data as $key => $value) {
$this->offsetSet($key, $value);
}
}
public function getKeys() {
return array_values($this->keys);
}
public function offsetExists($key) {
$key = $this->transformKey($key);
return array_key_exists($key, $this->keys);
}
public function offsetGet($key) {
$key = $this->transformKey($key);
return parent::offsetGet($this->keys[$key]);
}
public function offsetSet($key, $value) {
$transformed_key = $this->transformKey($key);
if (isset($this->keys[$transformed_key])) {
// If the key already exists, reuse it and override the
// existing value.
$key = $this->keys[$transformed_key];
} else {
// If the key doesn't yet, create a new array element.
$this->keys[$transformed_key] = $key;
}
parent::offsetSet($key, $value);
}
public function offsetUnset($key) {
$key = $this->transformKey($key);
parent::offsetUnset($this->keys[$key]);
unset($this->keys[$key]);
}
/**
* Transform an array key.
*
* This method transforms an array key to be case-invariant. We //could//
* just call [[http://php.net/manual/en/function.strtolower.php |
* `strtolower`]] directly, but this method allows us to contain the key
* transformation logic within a single method, should it ever change.
*
* Theoretically, we should be able to use any of the following functions
* for the purpose of key transformations:
*
* - [[http://php.net/manual/en/function.strtolower.php | `strtolower`]]
* - [[http://php.net/manual/en/function.strtoupper.php | `strtoupper`]]
* - Some creative use of other
* [[http://php.net/manual/en/book.strings.php | string transformation]]
* functions.
*
- * @param string The input key.
+ * @param string $key The input key.
* @return string The transformed key.
*/
private function transformKey($key) {
return phutil_utf8_strtolower($key);
}
}
diff --git a/src/utils/PhutilBufferedIterator.php b/src/utils/PhutilBufferedIterator.php
index 3f40b65d..598629f7 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 Page size.
+ * @param int $size Page size.
* @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/PhutilCallbackFilterIterator.php b/src/utils/PhutilCallbackFilterIterator.php
index c1a95a4d..1eb367ea 100644
--- a/src/utils/PhutilCallbackFilterIterator.php
+++ b/src/utils/PhutilCallbackFilterIterator.php
@@ -1,26 +1,26 @@
<?php
/**
* NOTE: This class has the same purpose as `CallbackFilterIterator` in PHP 5.4.
*/
final class PhutilCallbackFilterIterator extends FilterIterator {
private $callback;
/**
- * @param Iterator
- * @param callable Signature: (mixed $current): bool.
+ * @param Iterator $iterator
+ * @param callable $callback Signature: (mixed $current): bool.
*/
public function __construct(Iterator $iterator, $callback) {
parent::__construct($iterator);
if (!is_callable($callback)) {
throw new Exception(pht('Callback must be callable.'));
}
$this->callback = $callback;
}
#[\ReturnTypeWillChange]
public function accept() {
return call_user_func($this->callback, $this->current());
}
}
diff --git a/src/utils/PhutilChunkedIterator.php b/src/utils/PhutilChunkedIterator.php
index a002c5fe..d4a66894 100644
--- a/src/utils/PhutilChunkedIterator.php
+++ b/src/utils/PhutilChunkedIterator.php
@@ -1,60 +1,60 @@
<?php
/**
* This is an iterator version of `array_chunk()`.
*/
final class PhutilChunkedIterator extends Phobject implements Iterator {
private $iterator;
private $size;
private $key = 0;
private $current;
/**
- * @param Iterator
- * @param int
+ * @param Iterator $iterator
+ * @param int $size
*/
public function __construct(Iterator $iterator, $size) {
$this->iterator = $iterator;
$this->size = $size;
}
public function rewind() {
$this->iterator->rewind();
$this->next();
$this->key = 0;
}
/**
* @return int
*/
public function key() {
return $this->key;
}
/**
* @return array
*/
public function current() {
return $this->current;
}
public function next() {
$this->current = array();
while ($this->iterator->valid()) {
$key = $this->iterator->key();
$this->current[$key] = $this->iterator->current();
$this->iterator->next();
if (count($this->current) >= $this->size) {
break;
}
}
$this->key++;
}
public function valid() {
return (bool)$this->current;
}
}
diff --git a/src/utils/PhutilRope.php b/src/utils/PhutilRope.php
index 31d7aa27..76a26b41 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 to append.
+ * @param string $string String to append.
* @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 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 Maximum number of bytes to read.
+ * @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 Bytes to remove.
+ * @param int $remove Bytes to remove.
* @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/utils/PhutilSystem.php b/src/utils/PhutilSystem.php
index d2c053a5..4b1a98b9 100644
--- a/src/utils/PhutilSystem.php
+++ b/src/utils/PhutilSystem.php
@@ -1,227 +1,227 @@
<?php
/**
* Interact with the operating system.
*
* @task stdio Interacting with Standard I/O
* @task memory Interacting with System Memory
*/
final class PhutilSystem extends Phobject {
private static $stdin = false;
private static $stderr = false;
private static $stdout = false;
/**
* @task stdio
*/
public static function getStdinHandle() {
if (self::$stdin === false) {
self::$stdin = self::getStdioHandle('STDIN');
}
return self::$stdin;
}
/**
* @task stdio
*/
public static function getStdoutHandle() {
if (self::$stdout === false) {
self::$stdout = self::getStdioHandle('STDOUT');
}
return self::$stdout;
}
/**
* @task stdio
*/
public static function getStderrHandle() {
if (self::$stderr === false) {
self::$stderr = self::getStdioHandle('STDERR');
}
return self::$stderr;
}
/**
* @task stdio
*/
public static function writeStderr($message) {
$stderr = self::getStderrHandle();
if ($stderr === null) {
return;
}
$message = phutil_string_cast($message);
@fwrite($stderr, $message);
}
/**
* @task stdio
*/
private static function getStdioHandle($ref) {
if (defined($ref)) {
return constant($ref);
}
return null;
}
/**
* Get information about total and free memory on the system.
*
* Because "free memory" is a murky concept, the interpretation of the values
* returned from this method will vary from system to system and the numbers
* themselves may be only roughly accurate.
*
* @return map<string, wild> Dictionary of memory information.
* @task memory
*/
public static function getSystemMemoryInformation() {
$meminfo_path = '/proc/meminfo';
if (Filesystem::pathExists($meminfo_path)) {
$meminfo_data = Filesystem::readFile($meminfo_path);
return self::parseMemInfo($meminfo_data);
} else if (Filesystem::binaryExists('vm_stat')) {
list($vm_stat_stdout) = execx('vm_stat');
return self::parseVMStat($vm_stat_stdout);
} else {
throw new Exception(
pht(
'Unable to access %s or `%s` on this system to '.
'get system memory information.',
'/proc/meminfo',
'vm_stat'));
}
}
/**
* Parse the output of `/proc/meminfo`.
*
* See @{method:getSystemMemoryInformation}. This method is used to get memory
* information on Linux.
*
- * @param string Raw `/proc/meminfo`.
+ * @param string $data Raw `/proc/meminfo`.
* @return map<string, wild> Parsed memory information.
* @task memory
*/
public static function parseMemInfo($data) {
$data = phutil_split_lines($data);
$map = array();
foreach ($data as $line) {
list($key, $value) = explode(':', $line, 2);
$key = trim($key);
$value = trim($value);
$matches = null;
if (preg_match('/^(\d+) kB\z/', $value, $matches)) {
$value = (int)$matches[1] * 1024;
}
$map[$key] = $value;
}
$expect = array(
'MemTotal',
'MemFree',
'Buffers',
'Cached',
);
foreach ($expect as $key) {
if (!array_key_exists($key, $map)) {
throw new Exception(
pht(
'Expected to find "%s" in "%s" output, but did not.',
$key,
'/proc/meminfo'));
}
}
$total = $map['MemTotal'];
$free = $map['MemFree'] + $map['Buffers'] + $map['Cached'];
return array(
'total' => $total,
'free' => $free,
);
}
/**
* Parse the output of `vm_stat`.
*
* See @{method:getSystemMemoryInformation}. This method is used to get memory
* information on Mac OS X.
*
- * @param string Raw `vm_stat` output.
+ * @param string $data Raw `vm_stat` output.
* @return map<string, wild> Parsed memory information.
* @task memory
*/
public static function parseVMStat($data) {
$data = phutil_split_lines($data);
$page_size = null;
$map = array();
foreach ($data as $line) {
list($key, $value) = explode(':', $line, 2);
$key = trim($key);
$value = trim($value);
$matches = null;
if (preg_match('/page size of (\d+) bytes/', $value, $matches)) {
$page_size = (int)$matches[1];
continue;
}
$value = trim($value, '.');
$map[$key] = $value;
}
if (!$page_size) {
throw new Exception(
pht(
'Expected to find "%s" in `%s` output, but did not.',
'page size',
'vm_stat'));
}
$expect = array(
'Pages free',
'Pages active',
'Pages inactive',
'Pages wired down',
);
foreach ($expect as $key) {
if (!array_key_exists($key, $map)) {
throw new Exception(
pht(
'Expected to find "%s" in `%s` output, but did not.',
$key,
'vm_stat'));
}
}
// NOTE: This calculation probably isn't quite right. In particular,
// the numbers don't exactly add up, and "Pages inactive" includes a
// bunch of disk cache. So these numbers aren't totally reliable and they
// aren't directly comparable to the /proc/meminfo numbers.
$free = $map['Pages free'];
$active = $map['Pages active'];
$inactive = $map['Pages inactive'];
$wired = $map['Pages wired down'];
return array(
'total' => ($free + $active + $inactive + $wired) * $page_size,
'free' => ($free) * $page_size,
);
}
}
diff --git a/src/utils/utf8.php b/src/utils/utf8.php
index 7ba7b11e..ef1b2551 100644
--- a/src/utils/utf8.php
+++ b/src/utils/utf8.php
@@ -1,990 +1,992 @@
<?php
/**
* Convert a string into valid UTF-8. This function is quite slow.
*
* When invalid byte subsequences are encountered, they will be replaced with
* U+FFFD, the Unicode replacement character.
*
* This function treats overlong encodings as invalid.
*
- * @param string String to convert to valid UTF-8.
+ * @param string $string String to convert to valid UTF-8.
* @return string String with invalid UTF-8 byte subsequences replaced with
* U+FFFD.
*/
function phutil_utf8ize($string) {
if (phutil_is_utf8($string)) {
return $string;
}
// There is no function to do this in iconv, mbstring or ICU to do this, so
// do it (very very slowly) in pure PHP.
// TODO: Provide an optional fast C implementation ala fb_utf8ize() if this
// ever shows up in profiles?
$result = array();
$regex =
"/([\x01-\x7F]".
"|[\xC2-\xDF][\x80-\xBF]".
"|[\xE0][\xA0-\xBF][\x80-\xBF]".
"|[\xE1-\xEF][\x80-\xBF][\x80-\xBF]".
"|[\xF0][\x90-\xBF][\x80-\xBF][\x80-\xBF]".
"|[\xF1-\xF3][\x80-\xBF][\x80-\xBF][\x80-\xBF]".
"|[\xF4][\x80-\x8F][\x80-\xBF][\x80-\xBF])".
"|(.)/";
$replacement = "\xEF\xBF\xBD";
$offset = 0;
$matches = null;
while (preg_match($regex, $string, $matches, 0, $offset)) {
if (!isset($matches[2])) {
$match = $matches[1];
if ($match[0] == "\xED") {
// If this is a 3-byte character that may be part of one of the
// surrogate ranges, check if it's actually in those ranges. Reject
// it as invalid if it is. These sequences are used in UTF16 and
// functions like json_encode() refuse to encode them.
$codepoint = ((ord($match[0]) & 0x0F) << 12)
+ ((ord($match[1]) & 0x3F) << 6)
+ ((ord($match[2]) & 0x3F));
if ($codepoint >= 0xD800 && $codepoint <= 0xDFFF) {
$result[] = str_repeat($replacement, strlen($match));
$offset += strlen($matches[0]);
continue;
}
}
$result[] = $match;
} else {
// Unicode replacement character, U+FFFD.
$result[] = $replacement;
}
$offset += strlen($matches[0]);
}
return implode('', $result);
}
/**
* Determine if a string is valid UTF-8, with only basic multilingual plane
* characters. This is particularly important because MySQL's `utf8` column
* types silently truncate strings which contain characters outside of this
* set.
*
- * @param string String to test for being valid UTF-8 with only characters in
- * the basic multilingual plane.
+ * @param string $string String to test for being valid UTF-8 with only
+ * characters in the basic multilingual plane.
* @return bool True if the string is valid UTF-8 with only BMP characters.
*/
function phutil_is_utf8_with_only_bmp_characters($string) {
return phutil_is_utf8_slowly($string, $only_bmp = true);
}
/**
* Determine if a string is valid UTF-8.
*
- * @param string Some string which may or may not be valid UTF-8.
+ * @param string $string Some string which may or may not be valid UTF-8.
* @return bool True if the string is valid UTF-8.
*/
function phutil_is_utf8($string) {
if (function_exists('mb_check_encoding')) {
// See T13527. In some versions of PHP, "mb_check_encoding()" strictly
// requires a string parameter.
$string = phutil_string_cast($string);
// If mbstring is available, this is significantly faster than using PHP.
return mb_check_encoding($string, 'UTF-8');
}
return phutil_is_utf8_slowly($string);
}
/**
* Determine if a string is valid UTF-8, slowly.
*
* This works on any system, but has very poor performance.
*
* You should call @{function:phutil_is_utf8} instead of this function, as
* that function can use more performant mechanisms if they are available on
* the system.
*
- * @param string Some string which may or may not be valid UTF-8.
- * @param bool True to require all characters be part of the basic
- * multilingual plane (no more than 3-bytes long).
+ * @param string $string Some string which may or may not be valid UTF-8.
+ * @param bool $only_bmp (optional) True to require all characters be part
+ * of the basic multilingual plane (no more than 3-bytes long).
* @return bool True if the string is valid UTF-8.
*/
function phutil_is_utf8_slowly($string, $only_bmp = false) {
// First, check the common case of normal ASCII strings. We're fine if
// the string contains no bytes larger than 127.
if (preg_match('/^[\x01-\x7F]+\z/', $string)) {
return true;
}
// NOTE: In the past, we used a large regular expression in the form of
// '(x|y|z)+' to match UTF8 strings. However, PCRE can segfaults on patterns
// like this at relatively small input sizes, at least on some systems
// (observed on OSX and Windows). This is apparently because the internal
// implementation is recursive and it blows the stack.
// See <https://bugs.php.net/bug.php?id=45735> for some discussion. Since the
// input limit is extremely low (less than 50KB on my system), do this check
// very very slowly in PHP instead. See also T5316.
$len = strlen($string);
for ($ii = 0; $ii < $len; $ii++) {
$chr = ord($string[$ii]);
if ($chr >= 0x01 && $chr <= 0x7F) {
continue;
} else if ($chr >= 0xC2 && $chr <= 0xDF) {
++$ii;
if ($ii >= $len) {
return false;
}
$chr = ord($string[$ii]);
if ($chr >= 0x80 && $chr <= 0xBF) {
continue;
}
return false;
} else if ($chr == 0xED) {
// See T11525. Some sequences in this block are surrogate codepoints
// that are reserved for use in UTF16. We should reject them.
$codepoint = ($chr & 0x0F) << 12;
++$ii;
if ($ii >= $len) {
return false;
}
$chr = ord($string[$ii]);
$codepoint += ($chr & 0x3F) << 6;
if ($chr >= 0x80 && $chr <= 0xBF) {
++$ii;
if ($ii >= $len) {
return false;
}
$chr = ord($string[$ii]);
$codepoint += ($chr & 0x3F);
if ($codepoint >= 0xD800 && $codepoint <= 0xDFFF) {
// Reject these surrogate codepoints.
return false;
}
if ($chr >= 0x80 && $chr <= 0xBF) {
continue;
}
}
return false;
} else if ($chr > 0xE0 && $chr <= 0xEF) {
++$ii;
if ($ii >= $len) {
return false;
}
$chr = ord($string[$ii]);
if ($chr >= 0x80 && $chr <= 0xBF) {
++$ii;
if ($ii >= $len) {
return false;
}
$chr = ord($string[$ii]);
if ($chr >= 0x80 && $chr <= 0xBF) {
continue;
}
}
return false;
} else if ($chr == 0xE0) {
++$ii;
if ($ii >= $len) {
return false;
}
$chr = ord($string[$ii]);
// NOTE: This range starts at 0xA0, not 0x80. The values 0x80-0xA0 are
// "valid", but not minimal representations, and MySQL rejects them. We're
// special casing this part of the range.
if ($chr >= 0xA0 && $chr <= 0xBF) {
++$ii;
if ($ii >= $len) {
return false;
}
$chr = ord($string[$ii]);
if ($chr >= 0x80 && $chr <= 0xBF) {
continue;
}
}
return false;
} else if (!$only_bmp) {
if ($chr > 0xF0 && $chr <= 0xF4) {
++$ii;
if ($ii >= $len) {
return false;
}
$chr = ord($string[$ii]);
if ($chr >= 0x80 && $chr <= 0xBF) {
++$ii;
if ($ii >= $len) {
return false;
}
$chr = ord($string[$ii]);
if ($chr >= 0x80 && $chr <= 0xBF) {
++$ii;
if ($ii >= $len) {
return false;
}
$chr = ord($string[$ii]);
if ($chr >= 0x80 && $chr <= 0xBF) {
continue;
}
}
}
} else if ($chr == 0xF0) {
++$ii;
if ($ii >= $len) {
return false;
}
$chr = ord($string[$ii]);
// NOTE: As above, this range starts at 0x90, not 0x80. The values
// 0x80-0x90 are not minimal representations.
if ($chr >= 0x90 && $chr <= 0xBF) {
++$ii;
if ($ii >= $len) {
return false;
}
$chr = ord($string[$ii]);
if ($chr >= 0x80 && $chr <= 0xBF) {
++$ii;
if ($ii >= $len) {
return false;
}
$chr = ord($string[$ii]);
if ($chr >= 0x80 && $chr <= 0xBF) {
continue;
}
}
}
}
}
return false;
}
return true;
}
/**
* Find the character length of a UTF-8 string.
*
- * @param string A valid utf-8 string.
+ * @param string $string A valid utf-8 string.
* @return int The character length of the string.
*/
function phutil_utf8_strlen($string) {
if (function_exists('mb_strlen')) {
// Historically, this was just a call to strlen(utf8_decode($string))
// but, since PHP 8.2, that function is deprecated, so this is
// the current equivalent.
// https://we.phorge.it/T15188
return mb_strlen($string, 'UTF-8');
}
return count(phutil_utf8v($string));
}
/**
* Find the console display length of a UTF-8 string. This may differ from the
* character length of the string if it contains double-width characters, like
* many Chinese characters.
*
* This method is based on a C implementation here, which is based on the IEEE
* standards. The source has more discussion and addresses more considerations
* than this implementation does.
*
* http://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c
*
* NOTE: We currently assume width 1 for East-Asian ambiguous characters.
*
* NOTE: This function is VERY slow.
*
- * @param string A valid UTF-8 string.
+ * @param string $string A valid UTF-8 string.
* @return int The console display length of the string.
*/
function phutil_utf8_console_strlen($string) {
$string = phutil_string_cast($string);
// Formatting and colors don't contribute any width in the console.
$string = preg_replace("/\x1B\[\d*m/", '', $string);
// In the common case of an ASCII string, just return the string length.
if (preg_match('/^[\x01-\x7F]*\z/', $string)) {
return strlen($string);
}
$len = 0;
// NOTE: To deal with combining characters, we're splitting the string into
// glyphs first (characters with combiners) and then counting just the width
// of the first character in each glyph.
$display_glyphs = phutil_utf8v_combined($string);
foreach ($display_glyphs as $display_glyph) {
$glyph_codepoints = phutil_utf8v_codepoints($display_glyph);
foreach ($glyph_codepoints as $c) {
if ($c == 0) {
break;
}
$len += 1 +
($c >= 0x1100 &&
($c <= 0x115F || /* Hangul Jamo init. consonants */
$c == 0x2329 || $c == 0x232A ||
($c >= 0x2E80 && $c <= 0xA4CF &&
$c != 0x303F) || /* CJK ... Yi */
($c >= 0xAC00 && $c <= 0xD7A3) || /* Hangul Syllables */
($c >= 0xF900 && $c <= 0xFAFF) || /* CJK Compatibility Ideographs */
($c >= 0xFE10 && $c <= 0xFE19) || /* Vertical forms */
($c >= 0xFE30 && $c <= 0xFE6F) || /* CJK Compatibility Forms */
($c >= 0xFF00 && $c <= 0xFF60) || /* Fullwidth Forms */
($c >= 0xFFE0 && $c <= 0xFFE6) ||
($c >= 0x20000 && $c <= 0x2FFFD) ||
($c >= 0x30000 && $c <= 0x3FFFD)));
break;
}
}
return $len;
}
/**
* Test if a string contains Chinese, Japanese, or Korean characters.
*
* Most languages use spaces to separate words, but these languages do not.
*
- * @param string String to examine, in UTF8.
+ * @param string $string String to examine, in UTF8.
* @return bool True if the string contains Chinese, Japanese, or Korean
* characters.
*/
function phutil_utf8_is_cjk($string) {
$codepoints = phutil_utf8v_codepoints($string);
foreach ($codepoints as $codepoint) {
// CJK Unified Ideographs
if ($codepoint >= 0x4E00 && $codepoint <= 0x9FFF) {
return true;
}
// CJK Unified Ideographs Extension A
if ($codepoint >= 0x3400 && $codepoint <= 0x4DBF) {
return true;
}
// CJK Unified Ideographs Extension B
if ($codepoint >= 0x20000 && $codepoint <= 0x2A6DF) {
return true;
}
// CJK Unified Ideographs Extension C
if ($codepoint >= 0x2A700 && $codepoint <= 0x2B73F) {
return true;
}
// CJK Unified Ideographs Extension D
if ($codepoint >= 0x2B740 && $codepoint <= 0x2B81F) {
return true;
}
// CJK Unified Ideographs Extension E
if ($codepoint >= 0x2B820 && $codepoint <= 0x2CEAF) {
return true;
}
// CJK Unified Ideographs Extension F
if ($codepoint >= 0x2CEB0 && $codepoint <= 0x2EBEF) {
return true;
}
// CJK Compatibility Ideographs
if ($codepoint >= 0xF900 && $codepoint <= 0xFAFF) {
return true;
}
}
return false;
}
/**
* Split a UTF-8 string into an array of characters. Combining characters are
* also split.
*
- * @param string A valid utf-8 string.
- * @param int|null Stop processing after examining this many bytes.
+ * @param string $string A valid utf-8 string.
+ * @param int|null $byte_limit (optional) Stop processing after examining this
+ * many bytes.
* @return list A list of characters in the string.
*/
function phutil_utf8v($string, $byte_limit = null) {
$string = phutil_string_cast($string);
$res = array();
$len = strlen($string);
$ii = 0;
while ($ii < $len) {
$byte = $string[$ii];
if ($byte <= "\x7F") {
$res[] = $byte;
$ii += 1;
if ($byte_limit && ($ii >= $byte_limit)) {
break;
}
continue;
} else if ($byte < "\xC0") {
throw new Exception(
pht('Invalid UTF-8 string passed to %s.', __FUNCTION__));
} else if ($byte <= "\xDF") {
$seq_len = 2;
} else if ($byte <= "\xEF") {
$seq_len = 3;
} else if ($byte <= "\xF7") {
$seq_len = 4;
} else if ($byte <= "\xFB") {
$seq_len = 5;
} else if ($byte <= "\xFD") {
$seq_len = 6;
} else {
throw new Exception(
pht('Invalid UTF-8 string passed to %s.', __FUNCTION__));
}
if ($ii + $seq_len > $len) {
throw new Exception(
pht('Invalid UTF-8 string passed to %s.', __FUNCTION__));
}
for ($jj = 1; $jj < $seq_len; ++$jj) {
if ($string[$ii + $jj] >= "\xC0") {
throw new Exception(
pht('Invalid UTF-8 string passed to %s.', __FUNCTION__));
}
}
$res[] = substr($string, $ii, $seq_len);
$ii += $seq_len;
if ($byte_limit && ($ii >= $byte_limit)) {
break;
}
}
return $res;
}
/**
* Split a UTF-8 string into an array of codepoints (as integers).
*
- * @param string A valid UTF-8 string.
+ * @param string $string A valid UTF-8 string.
* @return list A list of codepoints, as integers.
*/
function phutil_utf8v_codepoints($string) {
$str_v = phutil_utf8v($string);
foreach ($str_v as $key => $char) {
$c = ord($char[0]);
$v = 0;
if (($c & 0x80) == 0) {
$v = $c;
} else if (($c & 0xE0) == 0xC0) {
$v = (($c & 0x1F) << 6)
+ ((ord($char[1]) & 0x3F));
} else if (($c & 0xF0) == 0xE0) {
$v = (($c & 0x0F) << 12)
+ ((ord($char[1]) & 0x3F) << 6)
+ ((ord($char[2]) & 0x3F));
} else if (($c & 0xF8) == 0xF0) {
$v = (($c & 0x07) << 18)
+ ((ord($char[1]) & 0x3F) << 12)
+ ((ord($char[2]) & 0x3F) << 6)
+ ((ord($char[3]) & 0x3F));
} else if (($c & 0xFC) == 0xF8) {
$v = (($c & 0x03) << 24)
+ ((ord($char[1]) & 0x3F) << 18)
+ ((ord($char[2]) & 0x3F) << 12)
+ ((ord($char[3]) & 0x3F) << 6)
+ ((ord($char[4]) & 0x3F));
} else if (($c & 0xFE) == 0xFC) {
$v = (($c & 0x01) << 30)
+ ((ord($char[1]) & 0x3F) << 24)
+ ((ord($char[2]) & 0x3F) << 18)
+ ((ord($char[3]) & 0x3F) << 12)
+ ((ord($char[4]) & 0x3F) << 6)
+ ((ord($char[5]) & 0x3F));
}
$str_v[$key] = $v;
}
return $str_v;
}
/**
* Convert a Unicode codepoint into a UTF8-encoded string.
*
- * @param int Unicode codepoint.
+ * @param int $codepoint Unicode codepoint.
* @return string UTF8 encoding.
*/
function phutil_utf8_encode_codepoint($codepoint) {
if ($codepoint < 0x80) {
$r = chr($codepoint);
} else if ($codepoint < 0x800) {
$r = chr(0xC0 | (($codepoint >> 6) & 0x1F)).
chr(0x80 | (($codepoint) & 0x3F));
} else if ($codepoint < 0x10000) {
$r = chr(0xE0 | (($codepoint >> 12) & 0x0F)).
chr(0x80 | (($codepoint >> 6) & 0x3F)).
chr(0x80 | (($codepoint) & 0x3F));
} else if ($codepoint < 0x110000) {
$r = chr(0xF0 | (($codepoint >> 18) & 0x07)).
chr(0x80 | (($codepoint >> 12) & 0x3F)).
chr(0x80 | (($codepoint >> 6) & 0x3F)).
chr(0x80 | (($codepoint) & 0x3F));
} else {
throw new Exception(
pht(
'Encoding UTF8 codepoint "%s" is not supported.',
$codepoint));
}
return $r;
}
/**
* Hard-wrap a block of UTF-8 text with embedded HTML tags and entities.
*
- * @param string An HTML string with tags and entities.
+ * @param string $string An HTML string with tags and entities.
+ * @param int $width Width of the hard-wrapped lines
* @return list List of hard-wrapped lines.
*/
function phutil_utf8_hard_wrap_html($string, $width) {
$break_here = array();
// Convert the UTF-8 string into a list of UTF-8 characters.
$vector = phutil_utf8v($string);
$len = count($vector);
$char_pos = 0;
for ($ii = 0; $ii < $len; ++$ii) {
// An ampersand indicates an HTML entity; consume the whole thing (until
// ";") but treat it all as one character.
if ($vector[$ii] == '&') {
do {
++$ii;
} while ($vector[$ii] != ';');
++$char_pos;
// An "<" indicates an HTML tag, consume the whole thing but don't treat
// it as a character.
} else if ($vector[$ii] == '<') {
do {
++$ii;
} while ($vector[$ii] != '>');
} else {
++$char_pos;
}
// Keep track of where we need to break the string later.
if ($char_pos == $width) {
$break_here[$ii] = true;
$char_pos = 0;
}
}
$result = array();
$string = '';
foreach ($vector as $ii => $char) {
$string .= $char;
if (isset($break_here[$ii])) {
$result[] = $string;
$string = '';
}
}
if (strlen($string)) {
$result[] = $string;
}
return $result;
}
/**
* Hard-wrap a block of UTF-8 text with no embedded HTML tags and entities.
*
- * @param string A non HTML string
- * @param int Width of the hard-wrapped lines
+ * @param string $string A non HTML string
+ * @param int $width Width of the hard-wrapped lines
* @return list List of hard-wrapped lines.
*/
function phutil_utf8_hard_wrap($string, $width) {
$result = array();
$lines = phutil_split_lines($string, $retain_endings = false);
foreach ($lines as $line) {
// Convert the UTF-8 string into a list of UTF-8 characters.
$vector = phutil_utf8v($line);
$len = count($vector);
$buffer = '';
for ($ii = 1; $ii <= $len; ++$ii) {
$buffer .= $vector[$ii - 1];
if (($ii % $width) === 0) {
$result[] = $buffer;
$buffer = '';
}
}
if (strlen($buffer)) {
$result[] = $buffer;
}
}
return $result;
}
/**
* Convert a string from one encoding (like ISO-8859-1) to another encoding
* (like UTF-8).
*
* This is primarily a thin wrapper around `mb_convert_encoding()` which checks
* you have the extension installed, since we try to require the extension
* only if you actually need it (i.e., you want to work with encodings other
* than UTF-8).
*
* NOTE: This function assumes that the input is in the given source encoding.
* If it is not, it may not output in the specified target encoding. If you
* need to perform a hard conversion to UTF-8, use this function in conjunction
* with @{function:phutil_utf8ize}. We can detect failures caused by invalid
* encoding names, but `mb_convert_encoding()` fails silently if the
* encoding name identifies a real encoding but the string is not actually
* encoded with that encoding.
*
- * @param string String to re-encode.
- * @param string Target encoding name, like "UTF-8".
- * @param string Source encoding name, like "ISO-8859-1".
+ * @param string $string String to re-encode.
+ * @param string $to_encoding Target encoding name, like "UTF-8".
+ * @param string $from_encoding Source encoding name, like "ISO-8859-1".
* @return string Input string, with converted character encoding.
*
* @phutil-external-symbol function mb_convert_encoding
*/
function phutil_utf8_convert($string, $to_encoding, $from_encoding) {
if (!$from_encoding) {
throw new InvalidArgumentException(
pht(
'Attempting to convert a string encoding, but no source encoding '.
'was provided. Explicitly provide the source encoding.'));
}
if (!$to_encoding) {
throw new InvalidArgumentException(
pht(
'Attempting to convert a string encoding, but no target encoding '.
'was provided. Explicitly provide the target encoding.'));
}
// Normalize encoding names so we can no-op the very common case of UTF8
// to UTF8 (or any other conversion where both encodings are identical).
$to_upper = strtoupper(str_replace('-', '', $to_encoding));
$from_upper = strtoupper(str_replace('-', '', $from_encoding));
if ($from_upper == $to_upper) {
return $string;
}
if (!function_exists('mb_convert_encoding')) {
throw new Exception(
pht(
"Attempting to convert a string encoding from '%s' to '%s', ".
"but the '%s' PHP extension is not available. Install %s to ".
"work with encodings other than UTF-8.",
$from_encoding,
$to_encoding,
'mbstring',
'mbstring'));
}
try {
$result = mb_convert_encoding($string, $to_encoding, $from_encoding);
} catch (Throwable $ex) {
$message = $ex->getMessage();
throw new Exception(
pht(
"String conversion from encoding '%s' to encoding '%s' failed: %s",
$from_encoding,
$to_encoding,
$message));
}
return $result;
}
/**
* Convert a string to title case in a UTF8-aware way. This function doesn't
* necessarily do a great job, but the builtin implementation of `ucwords()` can
* completely destroy inputs, so it just has to be better than that. Similar to
* @{function:ucwords}.
*
- * @param string UTF-8 input string.
+ * @param string $str UTF-8 input string.
* @return string Input, in some semblance of title case.
*/
function phutil_utf8_ucwords($str) {
// NOTE: mb_convert_case() discards uppercase letters in words when converting
// to title case. For example, it will convert "AAA" into "Aaa", which is
// undesirable.
$v = phutil_utf8v($str);
$result = '';
$last = null;
$ord_a = ord('a');
$ord_z = ord('z');
foreach ($v as $c) {
$convert = false;
if ($last === null || $last === ' ') {
$o = ord($c[0]);
if ($o >= $ord_a && $o <= $ord_z) {
$convert = true;
}
}
if ($convert) {
$result .= phutil_utf8_strtoupper($c);
} else {
$result .= $c;
}
$last = $c;
}
return $result;
}
/**
* Convert a string to lower case in a UTF8-aware way. Similar to
* @{function:strtolower}.
*
- * @param string UTF-8 input string.
+ * @param string $str UTF-8 input string.
* @return string Input, in some semblance of lower case.
*
* @phutil-external-symbol function mb_convert_case
*/
function phutil_utf8_strtolower($str) {
if ($str === null) {
return '';
}
if (function_exists('mb_convert_case')) {
return mb_convert_case($str, MB_CASE_LOWER, 'UTF-8');
}
static $map;
if ($map === null) {
$map = array_combine(
range('A', 'Z'),
range('a', 'z'));
}
return phutil_utf8_strtr($str, $map);
}
/**
* Convert a string to upper case in a UTF8-aware way. Similar to
* @{function:strtoupper}.
*
- * @param string UTF-8 input string.
+ * @param string $str UTF-8 input string.
* @return string Input, in some semblance of upper case.
*
* @phutil-external-symbol function mb_convert_case
*/
function phutil_utf8_strtoupper($str) {
if (function_exists('mb_convert_case')) {
return mb_convert_case($str, MB_CASE_UPPER, 'UTF-8');
}
static $map;
if ($map === null) {
$map = array_combine(
range('a', 'z'),
range('A', 'Z'));
}
return phutil_utf8_strtr($str, $map);
}
/**
* Replace characters in a string in a UTF-aware way. Similar to
* @{function:strtr}.
*
- * @param string UTF-8 input string.
- * @param map<string, string> Map of characters to replace.
+ * @param string $str UTF-8 input string.
+ * @param map<string, string> $map Map of characters to replace.
* @return string Input with translated characters.
*/
function phutil_utf8_strtr($str, array $map) {
$v = phutil_utf8v($str);
$result = '';
foreach ($v as $c) {
if (isset($map[$c])) {
$result .= $map[$c];
} else {
$result .= $c;
}
}
return $result;
}
/**
* Determine if a given unicode character is a combining character or not.
*
- * @param string A single unicode character.
+ * @param string $character A single unicode character.
* @return boolean True or false.
*/
function phutil_utf8_is_combining_character($character) {
$components = phutil_utf8v_codepoints($character);
// Combining Diacritical Marks (0300 - 036F).
// Combining Diacritical Marks Supplement (1DC0 - 1DFF).
// Combining Diacritical Marks for Symbols (20D0 - 20FF).
// Combining Half Marks (FE20 - FE2F).
foreach ($components as $codepoint) {
if ($codepoint >= 0x0300 && $codepoint <= 0x036F ||
$codepoint >= 0x1DC0 && $codepoint <= 0x1DFF ||
$codepoint >= 0x20D0 && $codepoint <= 0x20FF ||
$codepoint >= 0xFE20 && $codepoint <= 0xFE2F) {
return true;
}
}
return false;
}
/**
* Split a UTF-8 string into an array of characters. Combining characters
* are not split.
*
- * @param string A valid utf-8 string.
+ * @param string $string A valid utf-8 string.
* @return list A list of characters in the string.
*/
function phutil_utf8v_combined($string) {
$components = phutil_utf8v($string);
return phutil_utf8v_combine_characters($components);
}
/**
* Merge combining characters in a UTF-8 string.
*
* This is a low-level method which can allow other operations to do less work.
* If you have a string, call @{method:phutil_utf8v_combined} instead.
*
- * @param list List of UTF-8 characters.
+ * @param list $characters List of UTF-8 characters.
* @return list List of UTF-8 strings with combining characters merged.
*/
function phutil_utf8v_combine_characters(array $characters) {
if (!$characters) {
return array();
}
// If the first character in the string is a combining character,
// start with a space.
if (phutil_utf8_is_combining_character($characters[0])) {
$buf = ' ';
} else {
$buf = null;
}
$parts = array();
foreach ($characters as $character) {
if (!isset($character[1])) {
// This an optimization: there are no one-byte combining characters,
// so we can just pass these through unmodified.
$is_combining = false;
} else {
$is_combining = phutil_utf8_is_combining_character($character);
}
if ($is_combining) {
$buf .= $character;
} else {
if ($buf !== null) {
$parts[] = $buf;
}
$buf = $character;
}
}
$parts[] = $buf;
return $parts;
}
/**
* Return the current system locale setting (LC_ALL).
*
* @return string Current system locale setting.
*/
function phutil_get_system_locale() {
$locale = setlocale(LC_ALL, 0);
if ($locale === false) {
throw new Exception(
pht(
'Unable to determine current system locale (call to '.
'"setlocale(LC_ALL, 0)" failed).'));
}
return $locale;
}
/**
* Test if a system locale (LC_ALL) is available on the system.
*
- * @param string Locale name like "en_US.UTF-8".
+ * @param string $locale Locale name like "en_US.UTF-8".
* @return bool True if the locale is available.
*/
function phutil_is_system_locale_available($locale) {
$old_locale = phutil_get_system_locale();
$is_available = @setlocale(LC_ALL, $locale);
setlocale(LC_ALL, $old_locale);
return ($is_available !== false);
}
/**
* Set the system locale (LC_ALL) to a particular value.
*
- * @param string New locale setting.
+ * @param string $locale New locale setting.
* @return void
*/
function phutil_set_system_locale($locale) {
$ok = @setlocale(LC_ALL, $locale);
if (!$ok) {
throw new Exception(
pht(
'Failed to set system locale (to "%s").',
$locale));
}
}
diff --git a/src/utils/utils.php b/src/utils/utils.php
index 7043d9b8..58aa9c7a 100644
--- a/src/utils/utils.php
+++ b/src/utils/utils.php
@@ -1,2236 +1,2242 @@
<?php
/**
* Identity function, returns its argument unmodified.
*
* This is useful almost exclusively as a workaround to an oddity in the PHP
* grammar -- this is a syntax error:
*
* COUNTEREXAMPLE
* new Thing()->doStuff();
*
* ...but this works fine:
*
* id(new Thing())->doStuff();
*
* @template T
* @param T $x Anything
* @return T Unmodified argument.
*/
function id($x) {
return $x;
}
/**
* Access an array index, retrieving the value stored there if it exists or
* a default if it does not. This function allows you to concisely access an
* index which may or may not exist without raising a warning.
*
- * @param array Array to access.
- * @param scalar Index to access in the array.
- * @param wild Default value to return if the key is not present in the
- * array.
+ * @param array $array Array to access.
+ * @param scalar $key Index to access in the array.
+ * @param wild $default (optional) Default value to return if the key is
+ * not present in the array.
* @return wild If `$array[$key]` exists, that value is returned. If not,
* $default is returned without raising a warning.
*/
function idx(array $array, $key, $default = null) {
// isset() is a micro-optimization - it is fast but fails for null values.
if (isset($array[$key])) {
return $array[$key];
}
// Comparing $default is also a micro-optimization.
if ($default === null || array_key_exists($key, $array)) {
return null;
}
return $default;
}
/**
* Access a sequence of array indexes, retrieving a deeply nested value if
* it exists or a default if it does not.
*
* For example, `idxv($dict, array('a', 'b', 'c'))` accesses the key at
* `$dict['a']['b']['c']`, if it exists. If it does not, or any intermediate
* value is not itself an array, it returns the defualt value.
*
- * @param array Array to access.
- * @param list<string> List of keys to access, in sequence.
- * @param wild Default value to return.
+ * @param array $map Array to access.
+ * @param list<string> $path List of keys to access, in sequence.
+ * @param wild $default (optional) Default value to return.
* @return wild Accessed value, or default if the value is not accessible.
*/
function idxv(array $map, array $path, $default = null) {
if (!$path) {
return $default;
}
$last = last($path);
$path = array_slice($path, 0, -1);
$cursor = $map;
foreach ($path as $key) {
$cursor = idx($cursor, $key);
if (!is_array($cursor)) {
return $default;
}
}
return idx($cursor, $last, $default);
}
/**
* Call a method on a list of objects. Short for "method pull", this function
* works just like @{function:ipull}, except that it operates on a list of
* objects instead of a list of arrays. This function simplifies a common type
* of mapping operation:
*
* COUNTEREXAMPLE
* $names = array();
* foreach ($objects as $key => $object) {
* $names[$key] = $object->getName();
* }
*
* You can express this more concisely with mpull():
*
* $names = mpull($objects, 'getName');
*
* mpull() takes a third argument, which allows you to do the same but for
* the array's keys:
*
* COUNTEREXAMPLE
* $names = array();
* foreach ($objects as $object) {
* $names[$object->getID()] = $object->getName();
* }
*
* This is the mpull version():
*
* $names = mpull($objects, 'getName', 'getID');
*
* If you pass ##null## as the second argument, the objects will be preserved:
*
* COUNTEREXAMPLE
* $id_map = array();
* foreach ($objects as $object) {
* $id_map[$object->getID()] = $object;
* }
*
* With mpull():
*
* $id_map = mpull($objects, null, 'getID');
*
* See also @{function:ipull}, which works similarly but accesses array indexes
* instead of calling methods.
*
- * @param list Some list of objects.
- * @param string|null Determines which **values** will appear in the result
- * array. Use a string like 'getName' to store the
- * value of calling the named method in each value, or
- * ##null## to preserve the original objects.
- * @param string|null Determines how **keys** will be assigned in the result
- * array. Use a string like 'getID' to use the result
+ * @param list $list Some list of objects.
+ * @param string|null $method Determines which **values** will appear in
+ * the result array. Use a string like 'getName' to
+ * store the value of calling the named method in each
+ * value, or ##null## to preserve the original objects.
+ * @param string|null $key_method (optional) Determines how **keys** will
+ * be assigned in the result array.
+ * Use a string like 'getID' to use the result
* of calling the named method as each object's key, or
* `null` to preserve the original keys.
* @return dict A dictionary with keys and values derived according
* to whatever you passed as `$method` and `$key_method`.
*/
function mpull(array $list, $method, $key_method = null) {
$result = array();
foreach ($list as $key => $object) {
if ($key_method !== null) {
$key = $object->$key_method();
}
if ($method !== null) {
$value = $object->$method();
} else {
$value = $object;
}
$result[$key] = $value;
}
return $result;
}
/**
* Access a property on a list of objects. Short for "property pull", this
* function works just like @{function:mpull}, except that it accesses object
* properties instead of methods. This function simplifies a common type of
* mapping operation:
*
* COUNTEREXAMPLE
* $names = array();
* foreach ($objects as $key => $object) {
* $names[$key] = $object->name;
* }
*
* You can express this more concisely with ppull():
*
* $names = ppull($objects, 'name');
*
* ppull() takes a third argument, which allows you to do the same but for
* the array's keys:
*
* COUNTEREXAMPLE
* $names = array();
* foreach ($objects as $object) {
* $names[$object->id] = $object->name;
* }
*
* This is the ppull version():
*
* $names = ppull($objects, 'name', 'id');
*
* If you pass ##null## as the second argument, the objects will be preserved:
*
* COUNTEREXAMPLE
* $id_map = array();
* foreach ($objects as $object) {
* $id_map[$object->id] = $object;
* }
*
* With ppull():
*
* $id_map = ppull($objects, null, 'id');
*
* See also @{function:mpull}, which works similarly but calls object methods
* instead of accessing object properties.
*
- * @param list Some list of objects.
- * @param string|null Determines which **values** will appear in the result
- * array. Use a string like 'name' to store the value of
- * accessing the named property in each value, or
- * `null` to preserve the original objects.
- * @param string|null Determines how **keys** will be assigned in the result
- * array. Use a string like 'id' to use the result of
- * accessing the named property as each object's key, or
- * `null` to preserve the original keys.
+ * @param list $list Some list of objects.
+ * @param string|null $property Determines which **values** will appear in
+ * the result array. Use a string like 'name' to store
+ * the value of accessing the named property in each
+ * value, or `null` to preserve the original objects.
+ * @param string|null $key_property (optional) Determines how **keys** will
+ * be assigned in the result array. Use a string like
+ * 'id' to use the result of accessing the named property
+ * as each object's key, or `null` to preserve the
+ * original keys.
* @return dict A dictionary with keys and values derived according
* to whatever you passed as `$property` and
* `$key_property`.
*/
function ppull(array $list, $property, $key_property = null) {
$result = array();
foreach ($list as $key => $object) {
if ($key_property !== null) {
$key = $object->$key_property;
}
if ($property !== null) {
$value = $object->$property;
} else {
$value = $object;
}
$result[$key] = $value;
}
return $result;
}
/**
* Choose an index from a list of arrays. Short for "index pull", this function
* works just like @{function:mpull}, except that it operates on a list of
* arrays and selects an index from them instead of operating on a list of
* objects and calling a method on them.
*
* This function simplifies a common type of mapping operation:
*
* COUNTEREXAMPLE
* $names = array();
* foreach ($list as $key => $dict) {
* $names[$key] = $dict['name'];
* }
*
* With ipull():
*
* $names = ipull($list, 'name');
*
* See @{function:mpull} for more usage examples.
*
- * @param list Some list of arrays.
- * @param scalar|null Determines which **values** will appear in the result
- * array. Use a scalar to select that index from each
- * array, or null to preserve the arrays unmodified as
- * values.
- * @param scalar|null Determines which **keys** will appear in the result
- * array. Use a scalar to select that index from each
- * array, or null to preserve the array keys.
+ * @param list $list Some list of arrays.
+ * @param scalar|null $index Determines which **values** will appear in the
+ * result array. Use a scalar to select that index from
+ * each array, or null to preserve the arrays unmodified
+ * as values.
+ * @param scalar|null $key_index (optional) Determines which **keys** will
+ * appear in the result array. Use a scalar to select
+ * that index from each array, or null to preserve the
+ * array keys.
* @return dict A dictionary with keys and values derived according
* to whatever you passed for `$index` and `$key_index`.
*/
function ipull(array $list, $index, $key_index = null) {
$result = array();
foreach ($list as $key => $array) {
if ($key_index !== null) {
$key = $array[$key_index];
}
if ($index !== null) {
$value = $array[$index];
} else {
$value = $array;
}
$result[$key] = $value;
}
return $result;
}
/**
* Group a list of objects by the result of some method, similar to how
* GROUP BY works in an SQL query. This function simplifies grouping objects
* by some property:
*
* COUNTEREXAMPLE
* $animals_by_species = array();
* foreach ($animals as $animal) {
* $animals_by_species[$animal->getSpecies()][] = $animal;
* }
*
* This can be expressed more tersely with mgroup():
*
* $animals_by_species = mgroup($animals, 'getSpecies');
*
* In either case, the result is a dictionary which maps species (e.g., like
* "dog") to lists of animals with that property, so all the dogs are grouped
* together and all the cats are grouped together, or whatever super
* businessesey thing is actually happening in your problem domain.
*
* See also @{function:igroup}, which works the same way but operates on
* array indexes.
*
- * @param list List of objects to group by some property.
- * @param string Name of a method, like 'getType', to call on each object
- * in order to determine which group it should be placed into.
- * @param ... Zero or more additional method names, to subgroup the
- * groups.
+ * @param list $list List of objects to group by some property.
+ * @param string $by Name of a method, like 'getType', to call on each
+ * object in order to determine which group it should be
+ * placed into.
+ * @param ... (optional) Zero or more additional method names, to
+ * subgroup the groups.
* @return dict Dictionary mapping distinct method returns to lists of
* all objects which returned that value.
*/
function mgroup(array $list, $by /* , ... */) {
$map = mpull($list, $by);
$groups = array();
foreach ($map as $group) {
// Can't array_fill_keys() here because 'false' gets encoded wrong.
$groups[$group] = array();
}
foreach ($map as $key => $group) {
$groups[$group][$key] = $list[$key];
}
$args = func_get_args();
$args = array_slice($args, 2);
if ($args) {
array_unshift($args, null);
foreach ($groups as $group_key => $grouped) {
$args[0] = $grouped;
$groups[$group_key] = call_user_func_array('mgroup', $args);
}
}
return $groups;
}
/**
* Group a list of arrays by the value of some index. This function is the same
* as @{function:mgroup}, except it operates on the values of array indexes
* rather than the return values of method calls.
*
- * @param list List of arrays to group by some index value.
- * @param string Name of an index to select from each array in order to
+ * @param list $list List of arrays to group by some index value.
+ * @param string $by Name of an index to select from each array in order to
* determine which group it should be placed into.
- * @param ... Zero or more additional indexes names, to subgroup the
- * groups.
+ * @param ... (optional) Zero or more additional indexes names, to
+ * subgroup the groups.
* @return dict Dictionary mapping distinct index values to lists of
* all objects which had that value at the index.
*/
function igroup(array $list, $by /* , ... */) {
$map = ipull($list, $by);
$groups = array();
foreach ($map as $group) {
$groups[$group] = array();
}
foreach ($map as $key => $group) {
$groups[$group][$key] = $list[$key];
}
$args = func_get_args();
$args = array_slice($args, 2);
if ($args) {
array_unshift($args, null);
foreach ($groups as $group_key => $grouped) {
$args[0] = $grouped;
$groups[$group_key] = call_user_func_array('igroup', $args);
}
}
return $groups;
}
/**
* Sort a list of objects by the return value of some method. In PHP, this is
* often vastly more efficient than `usort()` and similar.
*
* // Sort a list of Duck objects by name.
* $sorted = msort($ducks, 'getName');
*
* It is usually significantly more efficient to define an ordering method
* on objects and call `msort()` than to write a comparator. It is often more
* convenient, as well.
*
* NOTE: This method does not take the list by reference; it returns a new list.
*
- * @param list List of objects to sort by some property.
- * @param string Name of a method to call on each object; the return values
- * will be used to sort the list.
+ * @param list $list List of objects to sort by some property.
+ * @param string $method Name of a method to call on each object; the return
+ * values will be used to sort the list.
* @return list Objects ordered by the return values of the method calls.
*/
function msort(array $list, $method) {
$surrogate = mpull($list, $method);
// See T13303. A "PhutilSortVector" is technically a sortable object, so
// a method which returns a "PhutilSortVector" is suitable for use with
// "msort()". However, it's almost certain that the caller intended to use
// "msortv()", not "msort()", and forgot to add a "v". Treat this as an error.
if ($surrogate) {
$item = head($surrogate);
if ($item instanceof PhutilSortVector) {
throw new Exception(
pht(
'msort() was passed a method ("%s") which returns '.
'"PhutilSortVector" objects. Use "msortv()", not "msort()", to '.
'sort a list which produces vectors.',
$method));
}
}
asort($surrogate);
$result = array();
foreach ($surrogate as $key => $value) {
$result[$key] = $list[$key];
}
return $result;
}
/**
* Sort a list of objects by a sort vector.
*
* This sort is stable, well-behaved, and more efficient than `usort()`.
*
- * @param list List of objects to sort.
- * @param string Name of a method to call on each object. The method must
- * return a @{class:PhutilSortVector}.
+ * @param list $list List of objects to sort.
+ * @param string $method Name of a method to call on each object. The method
+ * must return a @{class:PhutilSortVector}.
* @return list Objects ordered by the vectors.
*/
function msortv(array $list, $method) {
return msortv_internal($list, $method, SORT_STRING);
}
function msortv_natural(array $list, $method) {
return msortv_internal($list, $method, SORT_NATURAL | SORT_FLAG_CASE);
}
function msortv_internal(array $list, $method, $flags) {
$surrogate = mpull($list, $method);
$index = 0;
foreach ($surrogate as $key => $value) {
if (!($value instanceof PhutilSortVector)) {
throw new Exception(
pht(
'Objects passed to "%s" must return sort vectors (objects of '.
'class "%s") from the specified method ("%s"). One object (with '.
'key "%s") did not.',
'msortv()',
'PhutilSortVector',
$method,
$key));
}
// Add the original index to keep the sort stable.
$value->addInt($index++);
$surrogate[$key] = (string)$value;
}
asort($surrogate, $flags);
$result = array();
foreach ($surrogate as $key => $value) {
$result[$key] = $list[$key];
}
return $result;
}
/**
* Sort a list of arrays by the value of some index. This method is identical to
* @{function:msort}, but operates on a list of arrays instead of a list of
* objects.
*
- * @param list List of arrays to sort by some index value.
- * @param string Index to access on each object; the return values
+ * @param list $list List of arrays to sort by some index value.
+ * @param string $index Index to access on each object; the return values
* will be used to sort the list.
* @return list Arrays ordered by the index values.
*/
function isort(array $list, $index) {
$surrogate = ipull($list, $index);
asort($surrogate);
$result = array();
foreach ($surrogate as $key => $value) {
$result[$key] = $list[$key];
}
return $result;
}
/**
* Filter a list of objects by executing a method across all the objects and
* filter out the ones with empty() results. this function works just like
* @{function:ifilter}, except that it operates on a list of objects instead
* of a list of arrays.
*
* For example, to remove all objects with no children from a list, where
* 'hasChildren' is a method name, do this:
*
* mfilter($list, 'hasChildren');
*
* The optional third parameter allows you to negate the operation and filter
* out nonempty objects. To remove all objects that DO have children, do this:
*
* mfilter($list, 'hasChildren', true);
*
- * @param array List of objects to filter.
- * @param string A method name.
- * @param bool Optionally, pass true to drop objects which pass the
- * filter instead of keeping them.
+ * @param array $list List of objects to filter.
+ * @param string $method A method name.
+ * @param bool $negate (optional) Pass true to drop objects which pass
+ * the filter instead of keeping them.
* @return array List of objects which pass the filter.
*/
function mfilter(array $list, $method, $negate = false) {
if (!is_string($method)) {
throw new InvalidArgumentException(pht('Argument method is not a string.'));
}
$result = array();
foreach ($list as $key => $object) {
$value = $object->$method();
if (!$negate) {
if (!empty($value)) {
$result[$key] = $object;
}
} else {
if (empty($value)) {
$result[$key] = $object;
}
}
}
return $result;
}
/**
* Filter a list of arrays by removing the ones with an empty() value for some
* index. This function works just like @{function:mfilter}, except that it
* operates on a list of arrays instead of a list of objects.
*
* For example, to remove all arrays without value for key 'username', do this:
*
* ifilter($list, 'username');
*
* The optional third parameter allows you to negate the operation and filter
* out nonempty arrays. To remove all arrays that DO have value for key
* 'username', do this:
*
* ifilter($list, 'username', true);
*
- * @param array List of arrays to filter.
- * @param scalar The index.
- * @param bool Optionally, pass true to drop arrays which pass the
- * filter instead of keeping them.
+ * @param array $list List of arrays to filter.
+ * @param scalar $index The index.
+ * @param bool $negate (optional) Pass true to drop arrays which pass
+ * the filter instead of keeping them.
* @return array List of arrays which pass the filter.
*/
function ifilter(array $list, $index, $negate = false) {
if (!is_scalar($index)) {
throw new InvalidArgumentException(pht('Argument index is not a scalar.'));
}
$result = array();
if (!$negate) {
foreach ($list as $key => $array) {
if (!empty($array[$index])) {
$result[$key] = $array;
}
}
} else {
foreach ($list as $key => $array) {
if (empty($array[$index])) {
$result[$key] = $array;
}
}
}
return $result;
}
/**
* Selects a list of keys from an array, returning a new array with only the
* key-value pairs identified by the selected keys, in the specified order.
*
* Note that since this function orders keys in the result according to the
* order they appear in the list of keys, there are effectively two common
* uses: either reducing a large dictionary to a smaller one, or changing the
* key order on an existing dictionary.
*
- * @param dict Dictionary of key-value pairs to select from.
- * @param list List of keys to select.
+ * @param dict $dict Dictionary of key-value pairs to select from.
+ * @param list $keys List of keys to select.
* @return dict Dictionary of only those key-value pairs where the key was
* present in the list of keys to select. Ordering is
* determined by the list order.
*/
function array_select_keys(array $dict, array $keys) {
$result = array();
foreach ($keys as $key) {
if (array_key_exists($key, $dict)) {
$result[$key] = $dict[$key];
}
}
return $result;
}
/**
* Checks if all values of array are instances of the passed class. Throws
* `InvalidArgumentException` if it isn't true for any value.
*
- * @param array
- * @param string Name of the class or 'array' to check arrays.
+ * @param array $arr
+ * @param string $class Name of the class or 'array' to check arrays.
* @return array Returns passed array.
*/
function assert_instances_of(array $arr, $class) {
$is_array = !strcasecmp($class, 'array');
foreach ($arr as $key => $object) {
if ($is_array) {
if (!is_array($object)) {
$given = gettype($object);
throw new InvalidArgumentException(
pht(
"Array item with key '%s' must be of type array, %s given.",
$key,
$given));
}
} else if (!($object instanceof $class)) {
$given = gettype($object);
if (is_object($object)) {
$given = pht('instance of %s', get_class($object));
}
throw new InvalidArgumentException(
pht(
"Array item with key '%s' must be an instance of %s, %s given.",
$key,
$class,
$given));
}
}
return $arr;
}
/**
* Assert that two arrays have the exact same keys, in any order.
*
- * @param map Array with expected keys.
- * @param map Array with actual keys.
+ * @param map $expect Array with expected keys.
+ * @param map $actual Array with actual keys.
* @return void
*/
function assert_same_keys(array $expect, array $actual) {
foreach ($expect as $key => $value) {
if (isset($actual[$key]) || array_key_exists($key, $actual)) {
continue;
}
throw new InvalidArgumentException(
pht(
'Expected to find key "%s", but it is not present.',
$key));
}
foreach ($actual as $key => $value) {
if (isset($expect[$key]) || array_key_exists($key, $expect)) {
continue;
}
throw new InvalidArgumentException(
pht(
'Found unexpected surplus key "%s" where no such key was expected.',
$key));
}
}
/**
* Assert that passed data can be converted to string.
*
- * @param string Assert that this data is valid.
+ * @param string $parameter Assert that this data is valid.
* @return void
*
* @task assert
*/
function assert_stringlike($parameter) {
switch (gettype($parameter)) {
case 'string':
case 'NULL':
case 'boolean':
case 'double':
case 'integer':
return;
case 'object':
if (method_exists($parameter, '__toString')) {
return;
}
break;
case 'array':
case 'resource':
case 'unknown type':
default:
break;
}
throw new InvalidArgumentException(
pht(
'Argument must be scalar or object which implements %s!',
'__toString()'));
}
/**
* Returns the first argument which is not strictly null, or `null` if there
* are no such arguments. Identical to the MySQL function of the same name.
*
* @param ... Zero or more arguments of any type.
* @return mixed First non-`null` arg, or null if no such arg exists.
*/
function coalesce(/* ... */) {
$args = func_get_args();
foreach ($args as $arg) {
if ($arg !== null) {
return $arg;
}
}
return null;
}
/**
* Similar to @{function:coalesce}, but less strict: returns the first
* non-`empty()` argument, instead of the first argument that is strictly
* non-`null`. If no argument is nonempty, it returns the last argument. This
* is useful idiomatically for setting defaults:
*
* $display_name = nonempty($user_name, $full_name, "Anonymous");
*
* @param ... Zero or more arguments of any type.
* @return mixed First non-`empty()` arg, or last arg if no such arg
* exists, or null if you passed in zero args.
*/
function nonempty(/* ... */) {
$args = func_get_args();
$result = null;
foreach ($args as $arg) {
$result = $arg;
if ($arg) {
break;
}
}
return $result;
}
/**
* Invokes the "new" operator with a vector of arguments. There is no way to
* `call_user_func_array()` on a class constructor, so you can instead use this
* function:
*
* $obj = newv($class_name, $argv);
*
* That is, these two statements are equivalent:
*
* $pancake = new Pancake('Blueberry', 'Maple Syrup', true);
* $pancake = newv('Pancake', array('Blueberry', 'Maple Syrup', true));
*
* DO NOT solve this problem in other, more creative ways! Three popular
* alternatives are:
*
* - Build a fake serialized object and unserialize it.
* - Invoke the constructor twice.
* - just use `eval()` lol
*
* These are really bad solutions to the problem because they can have side
* effects (e.g., __wakeup()) and give you an object in an otherwise impossible
* state. Please endeavor to keep your objects in possible states.
*
* If you own the classes you're doing this for, you should consider whether
* or not restructuring your code (for instance, by creating static
* construction methods) might make it cleaner before using `newv()`. Static
* constructors can be invoked with `call_user_func_array()`, and may give your
* class a cleaner and more descriptive API.
*
- * @param string The name of a class.
- * @param list Array of arguments to pass to its constructor.
+ * @param string $class_name The name of a class.
+ * @param list $argv Array of arguments to pass to its constructor.
* @return obj A new object of the specified class, constructed by passing
* the argument vector to its constructor.
*/
function newv($class_name, array $argv) {
$reflector = new ReflectionClass($class_name);
if ($argv) {
return $reflector->newInstanceArgs($argv);
} else {
return $reflector->newInstance();
}
}
/**
* Returns the first element of an array. Exactly like reset(), but doesn't
* choke if you pass it some non-referenceable value like the return value of
* a function.
*
- * @param array Array to retrieve the first element from.
+ * @param array $arr Array to retrieve the first element from.
* @return wild The first value of the array.
*/
function head(array $arr) {
return reset($arr);
}
/**
* Returns the last element of an array. This is exactly like `end()` except
* that it won't warn you if you pass some non-referencable array to
* it -- e.g., the result of some other array operation.
*
- * @param array Array to retrieve the last element from.
+ * @param array $arr Array to retrieve the last element from.
* @return wild The last value of the array.
*/
function last(array $arr) {
return end($arr);
}
/**
* Returns the first key of an array.
*
- * @param array Array to retrieve the first key from.
+ * @param array $arr Array to retrieve the first key from.
* @return int|string The first key of the array.
*/
function head_key(array $arr) {
reset($arr);
return key($arr);
}
/**
* Returns the last key of an array.
*
- * @param array Array to retrieve the last key from.
+ * @param array $arr Array to retrieve the last key from.
* @return int|string The last key of the array.
*/
function last_key(array $arr) {
end($arr);
return key($arr);
}
/**
* Merge a vector of arrays performantly. This has the same semantics as
* array_merge(), so these calls are equivalent:
*
* array_merge($a, $b, $c);
* array_mergev(array($a, $b, $c));
*
* However, when you have a vector of arrays, it is vastly more performant to
* merge them with this function than by calling array_merge() in a loop,
* because using a loop generates an intermediary array on each iteration.
*
- * @param list Vector of arrays to merge.
+ * @param list $arrayv Vector of arrays to merge.
* @return list Arrays, merged with array_merge() semantics.
*/
function array_mergev(array $arrayv) {
if (!$arrayv) {
return array();
}
foreach ($arrayv as $key => $item) {
if (!is_array($item)) {
throw new InvalidArgumentException(
pht(
'Expected all items passed to "array_mergev()" to be arrays, but '.
'argument with key "%s" has type "%s".',
$key,
gettype($item)));
}
}
// See T13588. In PHP8, "call_user_func_array()" will attempt to use
// "unnatural" array keys as named parameters, and then fail because
// "array_merge()" does not accept named parameters . Guarantee the list is
// a "natural" list to avoid this.
$arrayv = array_values($arrayv);
return call_user_func_array('array_merge', $arrayv);
}
/**
* Split a corpus of text into lines. This function splits on "\n", "\r\n", or
* a mixture of any of them.
*
* NOTE: This function does not treat "\r" on its own as a newline because none
* of SVN, Git or Mercurial do on any OS.
*
* @param string|PhutilSafeHTML $corpus Block of text to be split into lines.
- * @param bool If true, retain line endings in result strings.
+ * @param bool $retain_endings (optional) If true, retain line endings in
+ * result strings.
* @return list List of lines.
*
* @phutil-external-symbol class PhutilSafeHTML
* @phutil-external-symbol function phutil_safe_html
*/
function phutil_split_lines($corpus, $retain_endings = true) {
if (!phutil_nonempty_stringlike($corpus)) {
return array('');
}
// Split on "\r\n" or "\n".
if ($retain_endings) {
$lines = preg_split('/(?<=\n)/', $corpus);
} else {
$lines = preg_split('/\r?\n/', $corpus);
}
// If the text ends with "\n" or similar, we'll end up with an empty string
// at the end; discard it.
if (end($lines) == '') {
array_pop($lines);
}
if ($corpus instanceof PhutilSafeHTML) {
foreach ($lines as $key => $line) {
$lines[$key] = phutil_safe_html($line);
}
return $lines;
}
return $lines;
}
/**
* Simplifies a common use of `array_combine()`. Specifically, this:
*
* COUNTEREXAMPLE:
* if ($list) {
* $result = array_combine($list, $list);
* } else {
* // Prior to PHP 5.4, array_combine() failed if given empty arrays.
* $result = array();
* }
*
* ...is equivalent to this:
*
* $result = array_fuse($list);
*
- * @param list List of scalars.
+ * @param list $list (optional) List of scalars.
* @return dict Dictionary with inputs mapped to themselves.
*/
function array_fuse(array $list = null) {
if ($list) {
return array_combine($list, $list);
}
return array();
}
/**
* Add an element between every two elements of some array. That is, given a
* list `A, B, C, D`, and some element to interleave, `x`, this function returns
* `A, x, B, x, C, x, D`. This works like `implode()`, but does not concatenate
* the list into a string. In particular:
*
* implode('', array_interleave($x, $list));
*
* ...is equivalent to:
*
* implode($x, $list);
*
* This function does not preserve keys.
*
- * @param wild Element to interleave.
- * @param list List of elements to be interleaved.
+ * @param wild $interleave Element to interleave.
+ * @param list $array List of elements to be interleaved.
* @return list Original list with the new element interleaved.
*/
function array_interleave($interleave, array $array) {
$result = array();
foreach ($array as $item) {
$result[] = $item;
$result[] = $interleave;
}
array_pop($result);
return $result;
}
function phutil_is_windows() {
// We can also use PHP_OS, but that's kind of sketchy because it returns
// "WINNT" for Windows 7 and "Darwin" for Mac OS X. Practically, testing for
// DIRECTORY_SEPARATOR is more straightforward.
return (DIRECTORY_SEPARATOR != '/');
}
function phutil_is_hiphop_runtime() {
return (array_key_exists('HPHP', $_ENV) && $_ENV['HPHP'] === 1);
}
/**
* Converts a string to a loggable one, with unprintables and newlines escaped.
*
- * @param string Any string.
+ * @param string $string Any string.
* @return string String with control and newline characters escaped, suitable
* for printing on a single log line.
*/
function phutil_loggable_string($string) {
if (preg_match('/^[\x20-\x7E]+$/', $string)) {
return $string;
}
$result = '';
static $c_map = array(
'\\' => '\\\\',
"\n" => '\\n',
"\r" => '\\r',
"\t" => '\\t',
);
$len = strlen($string);
for ($ii = 0; $ii < $len; $ii++) {
$c = $string[$ii];
if (isset($c_map[$c])) {
$result .= $c_map[$c];
} else {
$o = ord($c);
if ($o < 0x20 || $o >= 0x7F) {
$result .= '\\x'.sprintf('%02X', $o);
} else {
$result .= $c;
}
}
}
return $result;
}
/**
* Perform an `fwrite()` which distinguishes between EAGAIN and EPIPE.
*
* PHP's `fwrite()` is broken, and never returns `false` for writes to broken
* nonblocking pipes: it always returns 0, and provides no straightforward
* mechanism for distinguishing between EAGAIN (buffer is full, can't write any
* more right now) and EPIPE or similar (no write will ever succeed).
*
* See: https://bugs.php.net/bug.php?id=39598
*
* If you call this method instead of `fwrite()`, it will attempt to detect
* when a zero-length write is caused by EAGAIN and return `0` only if the
* write really should be retried.
*
- * @param resource Socket or pipe stream.
- * @param string Bytes to write.
+ * @param resource $stream Socket or pipe stream.
+ * @param string $bytes Bytes to write.
* @return bool|int Number of bytes written, or `false` on any error (including
* errors which `fwrite()` can not detect, like a broken pipe).
*/
function phutil_fwrite_nonblocking_stream($stream, $bytes) {
if (!strlen($bytes)) {
return 0;
}
$result = @fwrite($stream, $bytes);
if ($result !== 0) {
// In cases where some bytes are witten (`$result > 0`) or
// an error occurs (`$result === false`), the behavior of fwrite() is
// correct. We can return the value as-is.
return $result;
}
// If we make it here, we performed a 0-length write. Try to distinguish
// between EAGAIN and EPIPE. To do this, we're going to `stream_select()`
// the stream, write to it again if PHP claims that it's writable, and
// consider the pipe broken if the write fails.
// (Signals received during the "fwrite()" do not appear to affect anything,
// see D20083.)
$read = array();
$write = array($stream);
$except = array();
$result = @stream_select($read, $write, $except, 0);
if ($result === false) {
// See T13243. If the select is interrupted by a signal, it may return
// "false" indicating an underlying EINTR condition. In this case, the
// results (notably, "$write") are not usable because "stream_select()"
// didn't update them.
// In this case, treat this stream as blocked and tell the caller to
// retry, since EINTR is the only condition we're currently aware of that
// can cause "fwrite()" to return "0" and "stream_select()" to return
// "false" on the same stream.
return 0;
}
if (!$write) {
// The stream isn't writable, so we conclude that it probably really is
// blocked and the underlying error was EAGAIN. Return 0 to indicate that
// no data could be written yet.
return 0;
}
// If we make it here, PHP **just** claimed that this stream is writable, so
// perform a write. If the write also fails, conclude that these failures are
// EPIPE or some other permanent failure.
$result = @fwrite($stream, $bytes);
if ($result !== 0) {
// The write worked or failed explicitly. This value is fine to return.
return $result;
}
// We performed a 0-length write, were told that the stream was writable, and
// then immediately performed another 0-length write. Conclude that the pipe
// is broken and return `false`.
return false;
}
/**
* Convert a human-readable unit description into a numeric one. This function
* allows you to replace this:
*
* COUNTEREXAMPLE
* $ttl = (60 * 60 * 24 * 30); // 30 days
*
* ...with this:
*
* $ttl = phutil_units('30 days in seconds');
*
* ...which is self-documenting and difficult to make a mistake with.
*
- * @param string Human readable description of a unit quantity.
+ * @param string $description Human readable description of a unit quantity.
* @return int Quantity of specified unit.
*/
function phutil_units($description) {
$matches = null;
if (!preg_match('/^(\d+) (\w+) in (\w+)$/', $description, $matches)) {
throw new InvalidArgumentException(
pht(
'Unable to parse unit specification (expected a specification in the '.
'form "%s"): %s',
'5 days in seconds',
$description));
}
$quantity = (int)$matches[1];
$src_unit = $matches[2];
$dst_unit = $matches[3];
$is_divisor = false;
switch ($dst_unit) {
case 'seconds':
switch ($src_unit) {
case 'second':
case 'seconds':
$factor = 1;
break;
case 'minute':
case 'minutes':
$factor = 60;
break;
case 'hour':
case 'hours':
$factor = 60 * 60;
break;
case 'day':
case 'days':
$factor = 60 * 60 * 24;
break;
default:
throw new InvalidArgumentException(
pht(
'This function can not convert from the unit "%s".',
$src_unit));
}
break;
case 'bytes':
switch ($src_unit) {
case 'byte':
case 'bytes':
$factor = 1;
break;
case 'bit':
case 'bits':
$factor = 8;
$is_divisor = true;
break;
default:
throw new InvalidArgumentException(
pht(
'This function can not convert from the unit "%s".',
$src_unit));
}
break;
case 'milliseconds':
switch ($src_unit) {
case 'second':
case 'seconds':
$factor = 1000;
break;
case 'minute':
case 'minutes':
$factor = 1000 * 60;
break;
case 'hour':
case 'hours':
$factor = 1000 * 60 * 60;
break;
case 'day':
case 'days':
$factor = 1000 * 60 * 60 * 24;
break;
default:
throw new InvalidArgumentException(
pht(
'This function can not convert from the unit "%s".',
$src_unit));
}
break;
case 'microseconds':
switch ($src_unit) {
case 'second':
case 'seconds':
$factor = 1000000;
break;
case 'minute':
case 'minutes':
$factor = 1000000 * 60;
break;
case 'hour':
case 'hours':
$factor = 1000000 * 60 * 60;
break;
case 'day':
case 'days':
$factor = 1000000 * 60 * 60 * 24;
break;
default:
throw new InvalidArgumentException(
pht(
'This function can not convert from the unit "%s".',
$src_unit));
}
break;
default:
throw new InvalidArgumentException(
pht(
'This function can not convert into the unit "%s".',
$dst_unit));
}
if ($is_divisor) {
if ($quantity % $factor) {
throw new InvalidArgumentException(
pht(
'"%s" is not an exact quantity.',
$description));
}
return (int)($quantity / $factor);
} else {
return $quantity * $factor;
}
}
/**
* Compute the number of microseconds that have elapsed since an earlier
* timestamp (from `microtime(true)`).
*
- * @param double Microsecond-precision timestamp, from `microtime(true)`.
+ * @param double $timestamp Microsecond-precision timestamp, from
+ * `microtime(true)`.
* @return int Elapsed microseconds.
*/
function phutil_microseconds_since($timestamp) {
if (!is_float($timestamp)) {
throw new Exception(
pht(
'Argument to "phutil_microseconds_since(...)" should be a value '.
'returned from "microtime(true)".'));
}
$delta = (microtime(true) - $timestamp);
$delta = 1000000 * $delta;
$delta = (int)$delta;
return $delta;
}
/**
* Decode a JSON dictionary.
*
- * @param string A string which ostensibly contains a JSON-encoded list or
- * dictionary.
+ * @param string $string A string which ostensibly contains a JSON-encoded
+ * list or dictionary.
* @return mixed Decoded list/dictionary.
*/
function phutil_json_decode($string) {
$result = @json_decode($string, true);
if (!is_array($result)) {
// Failed to decode the JSON. Try to use @{class:PhutilJSONParser} instead.
// This will probably fail, but will throw a useful exception.
$parser = new PhutilJSONParser();
$result = $parser->parse($string);
}
return $result;
}
/**
* Encode a value in JSON, raising an exception if it can not be encoded.
*
- * @param wild A value to encode.
+ * @param wild $value A value to encode.
* @return string JSON representation of the value.
*/
function phutil_json_encode($value) {
$result = @json_encode($value);
if ($result === false) {
$reason = phutil_validate_json($value);
if (function_exists('json_last_error')) {
$err = json_last_error();
if (function_exists('json_last_error_msg')) {
$msg = json_last_error_msg();
$extra = pht('#%d: %s', $err, $msg);
} else {
$extra = pht('#%d', $err);
}
} else {
$extra = null;
}
if ($extra) {
$message = pht(
'Failed to JSON encode value (%s): %s.',
$extra,
$reason);
} else {
$message = pht(
'Failed to JSON encode value: %s.',
$reason);
}
throw new Exception($message);
}
return $result;
}
/**
* Produce a human-readable explanation why a value can not be JSON-encoded.
*
- * @param wild Value to validate.
- * @param string Path within the object to provide context.
+ * @param wild $value Value to validate.
+ * @param string $path (optional) Path within the object to provide context.
* @return string|null Explanation of why it can't be encoded, or null.
*/
function phutil_validate_json($value, $path = '') {
if ($value === null) {
return;
}
if ($value === true) {
return;
}
if ($value === false) {
return;
}
if (is_int($value)) {
return;
}
if (is_float($value)) {
return;
}
if (is_array($value)) {
foreach ($value as $key => $subvalue) {
if (strlen($path)) {
$full_key = $path.' > ';
} else {
$full_key = '';
}
if (!phutil_is_utf8($key)) {
$full_key = $full_key.phutil_utf8ize($key);
return pht(
'Dictionary key "%s" is not valid UTF8, and cannot be JSON encoded.',
$full_key);
}
$full_key .= $key;
$result = phutil_validate_json($subvalue, $full_key);
if ($result !== null) {
return $result;
}
}
}
if (is_string($value)) {
if (!phutil_is_utf8($value)) {
$display = substr($value, 0, 256);
$display = phutil_utf8ize($display);
if (!strlen($path)) {
return pht(
'String value is not valid UTF8, and can not be JSON encoded: %s',
$display);
} else {
return pht(
'Dictionary value at key "%s" is not valid UTF8, and cannot be '.
'JSON encoded: %s',
$path,
$display);
}
}
}
return;
}
/**
* Decode an INI string.
*
- * @param string
+ * @param string $string
* @return mixed
*/
function phutil_ini_decode($string) {
$results = null;
$trap = new PhutilErrorTrap();
try {
$have_call = false;
if (function_exists('parse_ini_string')) {
if (defined('INI_SCANNER_RAW')) {
$results = @parse_ini_string($string, true, INI_SCANNER_RAW);
$have_call = true;
}
}
if (!$have_call) {
throw new PhutilMethodNotImplementedException(
pht(
'%s is not compatible with your version of PHP (%s). This function '.
'is only supported on PHP versions newer than 5.3.0.',
__FUNCTION__,
phpversion()));
}
if ($results === false) {
throw new PhutilINIParserException(trim($trap->getErrorsAsString()));
}
foreach ($results as $section => $result) {
if (!is_array($result)) {
// We JSON decode the value in ordering to perform the following
// conversions:
//
// - `'true'` => `true`
// - `'false'` => `false`
// - `'123'` => `123`
// - `'1.234'` => `1.234`
//
$result = json_decode($result, true);
if ($result !== null && !is_array($result)) {
$results[$section] = $result;
}
continue;
}
foreach ($result as $key => $value) {
$value = json_decode($value, true);
if ($value !== null && !is_array($value)) {
$results[$section][$key] = $value;
}
}
}
} catch (Exception $ex) {
$trap->destroy();
throw $ex;
}
$trap->destroy();
return $results;
}
/**
* Attempt to censor any plaintext credentials from a string.
*
* The major use case here is to censor usernames and passwords from command
* output. For example, when `git fetch` fails, the output includes credentials
* for authenticated HTTP remotes.
*
- * @param string Some block of text.
+ * @param string $string Some block of text.
* @return string A similar block of text, but with credentials that could
* be identified censored.
*/
function phutil_censor_credentials($string) {
return preg_replace(',(?<=://)([^/@\s]+)(?=@|$),', '********', $string);
}
/**
* Returns a parsable string representation of a variable.
*
* This function is intended to behave similarly to PHP's `var_export` function,
* but the output is intended to follow our style conventions.
*
- * @param wild The variable you want to export.
+ * @param wild $var The variable you want to export.
* @return string
*/
function phutil_var_export($var) {
// `var_export(null, true)` returns `"NULL"` (in uppercase).
if ($var === null) {
return 'null';
}
// PHP's `var_export` doesn't format arrays very nicely. In particular:
//
// - An empty array is split over two lines (`"array (\n)"`).
// - A space separates "array" and the first opening brace.
// - Non-associative arrays are returned as associative arrays with an
// integer key.
//
if (is_array($var)) {
if (count($var) === 0) {
return 'array()';
}
// Don't show keys for non-associative arrays.
$show_keys = !phutil_is_natural_list($var);
$output = array();
$output[] = 'array(';
foreach ($var as $key => $value) {
// Adjust the indentation of the value.
$value = str_replace("\n", "\n ", phutil_var_export($value));
$output[] = ' '.
($show_keys ? var_export($key, true).' => ' : '').
$value.',';
}
$output[] = ')';
return implode("\n", $output);
}
// Let PHP handle everything else.
return var_export($var, true);
}
/**
* An improved version of `fnmatch`.
*
- * @param string A glob pattern.
- * @param string A path.
+ * @param string $glob A glob pattern.
+ * @param string $path A path.
* @return bool
*/
function phutil_fnmatch($glob, $path) {
// Modify the glob to allow `**/` to match files in the root directory.
$glob = preg_replace('@(?:(?<!\\\\)\\*){2}/@', '{,*/,**/}', $glob);
$escaping = false;
$in_curlies = 0;
$regex = '';
for ($i = 0; $i < strlen($glob); $i++) {
$char = $glob[$i];
$next_char = ($i < strlen($glob) - 1) ? $glob[$i + 1] : null;
$escape = array('$', '(', ')', '+', '.', '^', '|');
$mapping = array();
if ($escaping) {
$escape[] = '*';
$escape[] = '?';
$escape[] = '{';
} else {
$mapping['*'] = $next_char === '*' ? '.*' : '[^/]*';
$mapping['?'] = '[^/]';
$mapping['{'] = '(';
if ($in_curlies) {
$mapping[','] = '|';
$mapping['}'] = ')';
}
}
if (in_array($char, $escape)) {
$regex .= "\\{$char}";
} else if ($replacement = idx($mapping, $char)) {
$regex .= $replacement;
} else if ($char === '\\') {
if ($escaping) {
$regex .= '\\\\';
}
$escaping = !$escaping;
continue;
} else {
$regex .= $char;
}
if ($char === '{' && !$escaping) {
$in_curlies++;
} else if ($char === '}' && $in_curlies && !$escaping) {
$in_curlies--;
}
$escaping = false;
}
if ($in_curlies || $escaping) {
throw new InvalidArgumentException(pht('Invalid glob pattern.'));
}
$regex = '(\A'.$regex.'\z)';
return (bool)preg_match($regex, $path);
}
/**
* Compare two hashes for equality.
*
* This function defuses two attacks: timing attacks and type juggling attacks.
*
* In a timing attack, the attacker observes that strings which match the
* secret take slightly longer to fail to match because more characters are
* compared. By testing a large number of strings, they can learn the secret
* character by character. This defuses timing attacks by always doing the
* same amount of work.
*
* In a type juggling attack, an attacker takes advantage of PHP's type rules
* where `"0" == "0e12345"` for any exponent. A portion of of hexadecimal
* hashes match this pattern and are vulnerable. This defuses this attack by
* performing bytewise character-by-character comparison.
*
* It is questionable how practical these attacks are, but they are possible
* in theory and defusing them is straightforward.
*
- * @param string First hash.
- * @param string Second hash.
+ * @param string $u First hash.
+ * @param string $v Second hash.
* @return bool True if hashes are identical.
*/
function phutil_hashes_are_identical($u, $v) {
if (!is_string($u)) {
throw new Exception(pht('First hash argument must be a string.'));
}
if (!is_string($v)) {
throw new Exception(pht('Second hash argument must be a string.'));
}
if (strlen($u) !== strlen($v)) {
return false;
}
$len = strlen($v);
$bits = 0;
for ($ii = 0; $ii < $len; $ii++) {
$bits |= (ord($u[$ii]) ^ ord($v[$ii]));
}
return ($bits === 0);
}
/**
* Build a query string from a dictionary.
*
- * @param map<string, string> Dictionary of parameters.
+ * @param map<string, string> $parameters Dictionary of parameters.
* @return string HTTP query string.
*/
function phutil_build_http_querystring(array $parameters) {
$pairs = array();
foreach ($parameters as $key => $value) {
$pairs[] = array($key, $value);
}
return phutil_build_http_querystring_from_pairs($pairs);
}
/**
* Build a query string from a list of parameter pairs.
*
- * @param list<pair<string, string>> List of pairs.
+ * @param list<pair<string, string>> $pairs List of pairs.
* @return string HTTP query string.
*/
function phutil_build_http_querystring_from_pairs(array $pairs) {
// We want to encode in RFC3986 mode, but "http_build_query()" did not get
// a flag for that mode until PHP 5.4.0. This is equivalent to calling
// "http_build_query()" with the "PHP_QUERY_RFC3986" flag.
$query = array();
foreach ($pairs as $pair_key => $pair) {
if (!is_array($pair) || (count($pair) !== 2)) {
throw new Exception(
pht(
'HTTP parameter pair (with key "%s") is not valid: each pair must '.
'be an array with exactly two elements.',
$pair_key));
}
list($key, $value) = $pair;
list($key, $value) = phutil_http_parameter_pair($key, $value);
$query[] = rawurlencode($key).'='.rawurlencode($value);
}
$query = implode('&', $query);
return $query;
}
/**
* Typecheck and cast an HTTP key-value parameter pair.
*
* Scalar values are converted to strings. Nonscalar values raise exceptions.
*
- * @param scalar HTTP parameter key.
- * @param scalar HTTP parameter value.
+ * @param scalar $key HTTP parameter key.
+ * @param scalar $value HTTP parameter value.
* @return pair<string, string> Key and value as strings.
*/
function phutil_http_parameter_pair($key, $value) {
try {
assert_stringlike($key);
} catch (InvalidArgumentException $ex) {
throw new PhutilProxyException(
pht('HTTP query parameter key must be a scalar.'),
$ex);
}
$key = phutil_string_cast($key);
try {
assert_stringlike($value);
} catch (InvalidArgumentException $ex) {
throw new PhutilProxyException(
pht(
'HTTP query parameter value (for key "%s") must be a scalar.',
$key),
$ex);
}
$value = phutil_string_cast($value);
return array($key, $value);
}
function phutil_decode_mime_header($header) {
if (function_exists('iconv_mime_decode')) {
return iconv_mime_decode($header, 0, 'UTF-8');
}
if (function_exists('mb_decode_mimeheader')) {
return mb_decode_mimeheader($header);
}
throw new Exception(
pht(
'Unable to decode MIME header: install "iconv" or "mbstring" '.
'extension.'));
}
/**
* Perform a "(string)" cast without disabling standard exception behavior.
*
* When PHP invokes "__toString()" automatically, it fatals if the method
* raises an exception. In older versions of PHP (until PHP 7.1), this fatal is
* fairly opaque and does not give you any information about the exception
* itself, although newer versions of PHP at least include the exception
* message.
*
* This is documented on the "__toString()" manual page:
*
* Warning
* You cannot throw an exception from within a __toString() method. Doing
* so will result in a fatal error.
*
* However, this only applies to implicit invocation by the language runtime.
* Application code can safely call `__toString()` directly without any effect
* on exception handling behavior. Very cool.
*
* We also reject arrays. PHP casts them to the string "Array". This behavior
* is, charitably, evil.
*
- * @param wild Any value which aspires to be represented as a string.
+ * @param wild $value Any value which aspires to be represented as a string.
* @return string String representation of the provided value.
*/
function phutil_string_cast($value) {
if (is_array($value)) {
throw new Exception(
pht(
'Value passed to "phutil_string_cast()" is an array; arrays can '.
'not be sensibly cast to strings.'));
}
if (is_object($value)) {
$string = $value->__toString();
if (!is_string($string)) {
throw new Exception(
pht(
'Object (of class "%s") did not return a string from "__toString()".',
get_class($value)));
}
return $string;
}
return (string)$value;
}
/**
* Return a short, human-readable description of an object's type.
*
* This is mostly useful for raising errors like "expected x() to return a Y,
* but it returned a Z".
*
* This is similar to "get_type()", but describes objects and arrays in more
* detail.
*
- * @param wild Anything.
+ * @param wild $value Anything.
* @return string Human-readable description of the value's type.
*/
function phutil_describe_type($value) {
return PhutilTypeSpec::getTypeOf($value);
}
/**
* Test if a list has the natural numbers (1, 2, 3, and so on) as keys, in
* order.
*
* @return bool True if the list is a natural list.
*/
function phutil_is_natural_list(array $list) {
$expect = 0;
foreach ($list as $key => $item) {
if ($key !== $expect) {
return false;
}
$expect++;
}
return true;
}
/**
* Escape text for inclusion in a URI or a query parameter. Note that this
* method does NOT escape '/', because "%2F" is invalid in paths and Apache
* will automatically 404 the page if it's present. This will produce correct
* (the URIs will work) and desirable (the URIs will be readable) behavior in
* these cases:
*
* '/path/?param='.phutil_escape_uri($string); # OK: Query Parameter
* '/path/to/'.phutil_escape_uri($string); # OK: URI Suffix
*
* It will potentially produce the WRONG behavior in this special case:
*
* COUNTEREXAMPLE
* '/path/to/'.phutil_escape_uri($string).'/thing/'; # BAD: URI Infix
*
* In this case, any '/' characters in the string will not be escaped, so you
* will not be able to distinguish between the string and the suffix (unless
* you have more information, like you know the format of the suffix). For infix
* URI components, use @{function:phutil_escape_uri_path_component} instead.
*
- * @param string Some string.
+ * @param string $string Some string.
* @return string URI encoded string, except for '/'.
*/
function phutil_escape_uri($string) {
if ($string === null) {
return '';
}
return str_replace('%2F', '/', rawurlencode($string));
}
/**
* Escape text for inclusion as an infix URI substring. See discussion at
* @{function:phutil_escape_uri}. This function covers an unusual special case;
* @{function:phutil_escape_uri} is usually the correct function to use.
*
* This function will escape a string into a format which is safe to put into
* a URI path and which does not contain '/' so it can be correctly parsed when
* embedded as a URI infix component.
*
* However, you MUST decode the string with
* @{function:phutil_unescape_uri_path_component} before it can be used in the
* application.
*
- * @param string Some string.
+ * @param string $string Some string.
* @return string URI encoded string that is safe for infix composition.
*/
function phutil_escape_uri_path_component($string) {
return rawurlencode(rawurlencode($string));
}
/**
* Unescape text that was escaped by
* @{function:phutil_escape_uri_path_component}. See
* @{function:phutil_escape_uri} for discussion.
*
* Note that this function is NOT the inverse of
* @{function:phutil_escape_uri_path_component}! It undoes additional escaping
* which is added to survive the implied unescaping performed by the webserver
* when interpreting the request.
*
- * @param string Some string emitted
+ * @param string $string Some string emitted
* from @{function:phutil_escape_uri_path_component} and
* then accessed via a web server.
* @return string Original string.
*/
function phutil_unescape_uri_path_component($string) {
return rawurldecode($string);
}
function phutil_is_noninteractive() {
if (function_exists('posix_isatty') && !posix_isatty(STDIN)) {
return true;
}
return false;
}
function phutil_is_interactive() {
if (function_exists('posix_isatty') && posix_isatty(STDIN)) {
return true;
}
return false;
}
function phutil_encode_log($message) {
return addcslashes($message, "\0..\37\\\177..\377");
}
/**
* Insert a value in between each pair of elements in a list.
*
* Keys in the input list are preserved.
*/
function phutil_glue(array $list, $glue) {
if (!$list) {
return $list;
}
$last_key = last_key($list);
$keys = array();
$values = array();
$tmp = $list;
foreach ($list as $key => $ignored) {
$keys[] = $key;
if ($key !== $last_key) {
$tmp[] = $glue;
$keys[] = last_key($tmp);
}
}
return array_select_keys($tmp, $keys);
}
function phutil_partition(array $map) {
$partitions = array();
$partition = array();
$is_first = true;
$partition_value = null;
foreach ($map as $key => $value) {
if (!$is_first) {
if ($partition_value === $value) {
$partition[$key] = $value;
continue;
}
$partitions[] = $partition;
}
$is_first = false;
$partition = array($key => $value);
$partition_value = $value;
}
if ($partition) {
$partitions[] = $partition;
}
return $partitions;
}
function phutil_preg_match(
$pattern,
$subject,
$flags = 0,
$offset = 0) {
$matches = null;
$result = @preg_match($pattern, $subject, $matches, $flags, $offset);
if ($result === false || $result === null) {
phutil_raise_preg_exception(
'preg_match',
array(
$pattern,
$subject,
$matches,
$flags,
$offset,
));
}
return $matches;
}
function phutil_preg_match_all(
$pattern,
$subject,
$flags = 0,
$offset = 0) {
$matches = null;
$result = @preg_match_all($pattern, $subject, $matches, $flags, $offset);
if ($result === false || $result === null) {
phutil_raise_preg_exception(
'preg_match_all',
array(
$pattern,
$subject,
$matches,
$flags,
$offset,
));
}
return $matches;
}
function phutil_raise_preg_exception($function, array $argv) {
$trap = new PhutilErrorTrap();
// NOTE: This ugly construction to avoid issues with reference behavior when
// passing values through "call_user_func_array()".
switch ($function) {
case 'preg_match':
@preg_match($argv[0], $argv[1], $argv[2], $argv[3], $argv[4]);
break;
case 'preg_match_all':
@preg_match_all($argv[0], $argv[1], $argv[2], $argv[3], $argv[4]);
break;
}
$error_message = $trap->getErrorsAsString();
$trap->destroy();
$pattern = $argv[0];
$pattern_display = sprintf(
'"%s"',
addcslashes($pattern, '\\\"'));
$message = array();
$message[] = pht(
'Call to %s(%s, ...) failed.',
$function,
$pattern_display);
if (strlen($error_message)) {
$message[] = pht(
'Regular expression engine emitted message: %s',
$error_message);
}
$message = implode("\n\n", $message);
throw new PhutilRegexException($message);
}
/**
* Test if a value is a nonempty string.
*
* The value "null" and the empty string are considered empty; all other
* strings are considered nonempty.
*
* This method raises an exception if passed a value which is neither null
* nor a string.
*
- * @param Value to test.
+ * @param $value Value to test.
* @return bool True if the parameter is a nonempty string.
*/
function phutil_nonempty_string($value) {
if ($value === null) {
return false;
}
if ($value === '') {
return false;
}
if (is_string($value)) {
return true;
}
throw new InvalidArgumentException(
pht(
'Call to phutil_nonempty_string() expected null or a string, got: %s.',
phutil_describe_type($value)));
}
/**
* Test if a value is a nonempty, stringlike value.
*
* The value "null", the empty string, and objects which have a "__toString()"
* method which returns the empty string are empty.
*
* Other strings, and objects with a "__toString()" method that returns a
* string other than the empty string are considered nonempty.
*
* This method raises an exception if passed any other value.
*
- * @param Value to test.
+ * @param $value Value to test.
* @return bool True if the parameter is a nonempty, stringlike value.
*/
function phutil_nonempty_stringlike($value) {
if ($value === null) {
return false;
}
if ($value === '') {
return false;
}
if (is_string($value)) {
return true;
}
if (is_object($value)) {
try {
$string = phutil_string_cast($value);
return phutil_nonempty_string($string);
} catch (Exception $ex) {
// Continue below.
} catch (Throwable $ex) {
// Continue below.
}
}
throw new InvalidArgumentException(
pht(
'Call to phutil_nonempty_stringlike() expected a string or stringlike '.
'object, got: %s.',
phutil_describe_type($value)));
}
/**
* Test if a value is a nonempty, scalar value.
*
* The value "null", the empty string, and objects which have a "__toString()"
* method which returns the empty string are empty.
*
* Other strings, objects with a "__toString()" method which returns a
* string other than the empty string, integers, and floats are considered
* scalar.
*
* Note that booleans are also valid scalars, where false is considered empty,
* and true is non-empty since if you cast true to string, it's non-empty.
*
* This method raises an exception if passed any other value.
*
- * @param Value to test.
+ * @param $value Value to test.
* @return bool True if the parameter is a nonempty, scalar value.
*/
function phutil_nonempty_scalar($value) {
if ($value === null) {
return false;
}
if ($value === '') {
return false;
}
if (is_string($value) || is_int($value) || is_float($value)) {
return true;
}
// Booleans are also valid scalars by PHP. Inventing the opposite can be
// too much esoteric and problematic.
// false: empty, because casted to string becomes '' (empty)
// true: non-empty, because casted to string becomes '1' (non-empty)
if ($value === false || $value === true) {
return $value;
}
if (is_object($value)) {
try {
$string = phutil_string_cast($value);
return phutil_nonempty_string($string);
} catch (Exception $ex) {
// Continue below.
} catch (Throwable $ex) {
// Continue below.
}
}
throw new InvalidArgumentException(
pht(
'Call to phutil_nonempty_scalar() expected: a string; or stringlike '.
'object; or int; or float. Got: %s.',
phutil_describe_type($value)));
}
diff --git a/src/utils/viewutils.php b/src/utils/viewutils.php
index b660de42..4a056757 100644
--- a/src/utils/viewutils.php
+++ b/src/utils/viewutils.php
@@ -1,170 +1,170 @@
<?php
function phutil_date_format($epoch) {
$now = time();
$shift = 30 * 24 * 60 * 60;
if ($epoch < $now + $shift && $epoch > $now - $shift) {
$format = pht('D, M j');
} else {
$format = pht('M j Y');
}
return $format;
}
function phutil_format_relative_time($duration) {
return phutil_format_units_generic(
$duration,
array(60, 60, 24, 7),
array('s', 'm', 'h', 'd', 'w'),
$precision = 0);
}
/**
* Format a relative time (duration) into weeks, days, hours, minutes,
* seconds, but unlike phabricator_format_relative_time, does so for more than
* just the largest unit.
*
- * @param int Duration in seconds.
- * @param int Levels to render - will render the three highest levels, ie:
- * 5 h, 37 m, 1 s
+ * @param int $duration Duration in seconds.
+ * @param int $levels (optional) Levels to render. By default, renders the
+ * three highest levels, ie: 5 h, 37 m, 1 s
* @return string Human-readable description.
*/
function phutil_format_relative_time_detailed($duration, $levels = 2) {
if ($duration == 0) {
return 'now';
}
$levels = max(1, min($levels, 5));
$remainder = 0;
$is_negative = false;
if ($duration < 0) {
$is_negative = true;
$duration = abs($duration);
}
$this_level = 1;
$detailed_relative_time = phutil_format_units_generic(
$duration,
array(60, 60, 24, 7),
array('s', 'm', 'h', 'd', 'w'),
$precision = 0,
$remainder);
$duration = $remainder;
while ($remainder > 0 && $this_level < $levels) {
$detailed_relative_time .= ', '.phutil_format_units_generic(
$duration,
array(60, 60, 24, 7),
array('s', 'm', 'h', 'd', 'w'),
$precision = 0,
$remainder);
$duration = $remainder;
$this_level++;
}
if ($is_negative) {
$detailed_relative_time .= ' ago';
}
return $detailed_relative_time;
}
/**
* Format a byte count for human consumption, e.g. "10MB" instead of
* "10485760".
*
- * @param int Number of bytes.
+ * @param int $bytes Number of bytes.
* @return string Human-readable description.
*/
function phutil_format_bytes($bytes) {
return phutil_format_units_generic(
$bytes,
array(1024, 1024, 1024, 1024, 1024),
array('B', 'KB', 'MB', 'GB', 'TB', 'PB'),
$precision = 0);
}
/**
* Parse a human-readable byte description (like "6MB") into an integer.
*
- * @param string Human-readable description.
+ * @param string $input Human-readable description.
* @return int Number of represented bytes.
*/
function phutil_parse_bytes($input) {
$bytes = trim($input);
if (!strlen($bytes)) {
return null;
}
// NOTE: Assumes US-centric numeral notation.
$bytes = preg_replace('/[ ,]/', '', $bytes);
$matches = null;
if (!preg_match('/^(?:\d+(?:[.]\d+)?)([kmgtp]?)b?$/i', $bytes, $matches)) {
throw new Exception(pht("Unable to parse byte size '%s'!", $input));
}
$scale = array(
'k' => 1024,
'm' => 1024 * 1024,
'g' => 1024 * 1024 * 1024,
't' => 1024 * 1024 * 1024 * 1024,
'p' => 1024 * 1024 * 1024 * 1024 * 1024,
);
$bytes = (float)$bytes;
if ($matches[1]) {
$bytes *= $scale[strtolower($matches[1])];
}
return (int)$bytes;
}
function phutil_format_units_generic(
$n,
array $scales,
array $labels,
$precision = 0,
&$remainder = null) {
$is_negative = false;
if ($n < 0) {
$is_negative = true;
$n = abs($n);
}
$remainder = 0;
$accum = 1;
$scale = array_shift($scales);
$label = array_shift($labels);
while ($n >= $scale && count($labels)) {
$remainder += ((int)$n % $scale) * $accum;
$n /= $scale;
$accum *= $scale;
$label = array_shift($labels);
if (!count($scales)) {
break;
}
$scale = array_shift($scales);
}
if ($is_negative) {
$n = -$n;
$remainder = -$remainder;
}
if ($precision) {
$num_string = number_format($n, $precision);
} else {
$num_string = (int)floor($n);
}
if ($label) {
$num_string .= ' '.$label;
}
return $num_string;
}
diff --git a/src/workflow/ArcanistDiffWorkflow.php b/src/workflow/ArcanistDiffWorkflow.php
index a7d11fde..3c0afa84 100644
--- a/src/workflow/ArcanistDiffWorkflow.php
+++ b/src/workflow/ArcanistDiffWorkflow.php
@@ -1,2904 +1,2904 @@
<?php
/**
* Sends changes from your working copy to Differential for code review.
*
* @task lintunit Lint and Unit Tests
* @task message Commit and Update Messages
* @task diffspec Diff Specification
* @task diffprop Diff Properties
*/
final class ArcanistDiffWorkflow extends ArcanistWorkflow {
private $console;
private $hasWarnedExternals = false;
private $unresolvedLint;
private $testResults;
private $diffID;
private $revisionID;
private $diffPropertyFutures = array();
private $commitMessageFromRevision;
private $hitAutotargets;
private $revisionTransactions;
private $revisionIsDraft;
const STAGING_PUSHED = 'pushed';
const STAGING_USER_SKIP = 'user.skip';
const STAGING_DIFF_RAW = 'diff.raw';
const STAGING_REPOSITORY_UNKNOWN = 'repository.unknown';
const STAGING_REPOSITORY_UNAVAILABLE = 'repository.unavailable';
const STAGING_REPOSITORY_UNSUPPORTED = 'repository.unsupported';
const STAGING_REPOSITORY_UNCONFIGURED = 'repository.unconfigured';
const STAGING_CLIENT_UNSUPPORTED = 'client.unsupported';
public function getWorkflowName() {
return 'diff';
}
public function getCommandSynopses() {
return phutil_console_format(<<<EOTEXT
**diff** [__paths__] (svn)
**diff** [__commit__] (git, hg)
EOTEXT
);
}
public function getCommandHelp() {
return phutil_console_format(<<<EOTEXT
Supports: git, svn, hg
Generate a Differential diff or revision from local changes.
Under git and mercurial, you can specify a commit (like __HEAD^^^__
or __master__) and Differential will generate a diff against the
merge base of that commit and your current working directory parent.
Under svn, you can choose to include only some of the modified files
in the working copy in the diff by specifying their paths. If you
omit paths, all changes are included in the diff.
EOTEXT
);
}
public function requiresWorkingCopy() {
return !$this->isRawDiffSource();
}
public function requiresConduit() {
return true;
}
public function requiresAuthentication() {
return true;
}
public function requiresRepositoryAPI() {
if (!$this->isRawDiffSource()) {
return true;
}
return false;
}
public function getDiffID() {
return $this->diffID;
}
public function getArguments() {
$arguments = array(
'message' => array(
'short' => 'm',
'param' => 'message',
'help' => pht(
'When updating a revision, use the specified message instead of '.
'prompting.'),
),
'message-file' => array(
'short' => 'F',
'param' => 'file',
'paramtype' => 'file',
'help' => pht(
'When creating a revision, read revision information '.
'from this file.'),
),
'edit' => array(
'supports' => array(
'git',
'hg',
),
'nosupport' => array(
'svn' => pht('Edit revisions via the web interface when using SVN.'),
),
'help' => pht(
'When updating a revision under git, edit revision information '.
'before updating.'),
),
'raw' => array(
'help' => pht(
'Read diff from stdin, not from the working copy. This disables '.
'many features which depend on having access to the working copy.'),
'conflicts' => array(
'apply-patches' => pht('%s disables lint.', '--raw'),
'never-apply-patches' => pht('%s disables lint.', '--raw'),
'create' => pht(
'%s and %s both need stdin. Use %s.',
'--raw',
'--create',
'--raw-command'),
'edit' => pht(
'%s and %s both need stdin. Use %s.',
'--raw',
'--edit',
'--raw-command'),
'raw-command' => null,
),
),
'raw-command' => array(
'param' => 'command',
'help' => pht(
'Generate diff by executing a specified command, not from the '.
'working copy. This disables many features which depend on having '.
'access to the working copy.'),
'conflicts' => array(
'apply-patches' => pht('%s disables lint.', '--raw-command'),
'never-apply-patches' => pht('%s disables lint.', '--raw-command'),
),
),
'create' => array(
'help' => pht('Always create a new revision.'),
'conflicts' => array(
'edit' => pht(
'%s can not be used with %s.',
'--create',
'--edit'),
'only' => pht(
'%s can not be used with %s.',
'--create',
'--only'),
'update' => pht(
'%s can not be used with %s.',
'--create',
'--update'),
),
),
'update' => array(
'param' => 'revision_id',
'help' => pht('Always update a specific revision.'),
),
'draft' => array(
'help' => pht(
'Create a draft revision so you can look over your changes before '.
'involving anyone else. Other users will not be notified about the '.
'revision until you later use "Request Review" to publish it. You '.
'can still share the draft by giving someone the link.'),
'conflicts' => array(
'edit' => null,
'only' => null,
'update' => null,
),
),
'nounit' => array(
'help' => pht('Do not run unit tests.'),
),
'nolint' => array(
'help' => pht('Do not run lint.'),
'conflicts' => array(
'apply-patches' => pht('%s suppresses lint.', '--nolint'),
'never-apply-patches' => pht('%s suppresses lint.', '--nolint'),
),
),
'only' => array(
'help' => pht(
'Instead of creating or updating a revision, only create a diff, '.
'which you may later attach to a revision.'),
'conflicts' => array(
'edit' => pht('%s does affect revisions.', '--only'),
'message' => pht('%s does not update any revision.', '--only'),
),
),
'allow-untracked' => array(
'help' => pht('Skip checks for untracked files in the working copy.'),
),
'apply-patches' => array(
'help' => pht(
'Apply patches suggested by lint to the working copy without '.
'prompting.'),
'conflicts' => array(
'never-apply-patches' => true,
),
'passthru' => array(
'lint' => true,
),
),
'never-apply-patches' => array(
'help' => pht('Never apply patches suggested by lint.'),
'conflicts' => array(
'apply-patches' => true,
),
'passthru' => array(
'lint' => true,
),
),
'amend-all' => array(
'help' => pht(
'When linting git repositories, amend HEAD with all patches '.
'suggested by lint without prompting.'),
'passthru' => array(
'lint' => true,
),
),
'amend-autofixes' => array(
'help' => pht(
'When linting git repositories, amend HEAD with autofix '.
'patches suggested by lint without prompting.'),
'passthru' => array(
'lint' => true,
),
),
'add-all' => array(
'short' => 'a',
'help' => pht(
'Automatically add all unstaged and uncommitted '.
'files to the commit.'),
),
'json' => array(
'help' => pht(
'Emit machine-readable JSON. EXPERIMENTAL! Probably does not work!'),
),
'no-amend' => array(
'help' => pht(
'Never amend commits in the working copy with lint patches.'),
),
'uncommitted' => array(
'help' => pht('Suppress warning about uncommitted changes.'),
'supports' => array(
'hg',
),
),
'verbatim' => array(
'help' => pht(
'When creating a revision, try to use the working copy commit '.
'message verbatim, without prompting to edit it. When updating a '.
'revision, update some fields from the local commit message.'),
'supports' => array(
'hg',
'git',
),
'conflicts' => array(
'update' => true,
'only' => true,
'raw' => true,
'raw-command' => true,
'message-file' => true,
),
),
'reviewers' => array(
'param' => 'usernames',
'help' => pht('When creating a revision, add reviewers.'),
'conflicts' => array(
'only' => true,
'update' => true,
),
),
'cc' => array(
'param' => 'usernames',
'help' => pht('When creating a revision, add CCs.'),
'conflicts' => array(
'only' => true,
'update' => true,
),
),
'skip-binaries' => array(
'help' => pht('Do not upload binaries (like images).'),
),
'skip-staging' => array(
'help' => pht('Do not copy changes to the staging area.'),
),
'base' => array(
'param' => 'rules',
'help' => pht('Additional rules for determining base revision.'),
'nosupport' => array(
'svn' => pht('Subversion does not use base commits.'),
),
'supports' => array('git', 'hg'),
),
'coverage' => array(
'help' => pht('Always enable coverage information.'),
'conflicts' => array(
'no-coverage' => null,
),
'passthru' => array(
'unit' => true,
),
),
'no-coverage' => array(
'help' => pht('Always disable coverage information.'),
'passthru' => array(
'unit' => true,
),
),
'browse' => array(
'help' => pht(
'After creating a diff or revision, open it in a web browser.'),
),
'*' => 'paths',
'head' => array(
'param' => 'commit',
'help' => pht(
'Specify the end of the commit range. This disables many features '.
'which depend on having access to the working copy.'),
'supports' => array('git'),
'nosupport' => array(
'svn' => pht('Subversion does not support commit ranges.'),
'hg' => pht('Mercurial does not support %s yet.', '--head'),
),
),
);
return $arguments;
}
public function isRawDiffSource() {
return $this->getArgument('raw') || $this->getArgument('raw-command');
}
public function run() {
$this->console = PhutilConsole::getConsole();
$this->runRepositoryAPISetup();
$this->runDiffSetupBasics();
$commit_message = $this->buildCommitMessage();
$this->dispatchEvent(
ArcanistEventType::TYPE_DIFF_DIDBUILDMESSAGE,
array(
'message' => $commit_message,
));
if (!$this->shouldOnlyCreateDiff()) {
$revision = $this->buildRevisionFromCommitMessage($commit_message);
}
$data = $this->runLintUnit();
$lint_result = $data['lintResult'];
$this->unresolvedLint = $data['unresolvedLint'];
$unit_result = $data['unitResult'];
$this->testResults = $data['testResults'];
$changes = $this->generateChanges();
if (!$changes) {
throw new ArcanistUsageException(
pht('There are no changes to generate a diff from!'));
}
$diff_spec = array(
'changes' => mpull($changes, 'toDictionary'),
'lintStatus' => $this->getLintStatus($lint_result),
'unitStatus' => $this->getUnitStatus($unit_result),
) + $this->buildDiffSpecification();
$conduit = $this->getConduit();
$diff_info = $conduit->callMethodSynchronous(
'differential.creatediff',
$diff_spec);
$this->diffID = $diff_info['diffid'];
$event = $this->dispatchEvent(
ArcanistEventType::TYPE_DIFF_WASCREATED,
array(
'diffID' => $diff_info['diffid'],
'lintResult' => $lint_result,
'unitResult' => $unit_result,
));
$this->submitChangesToStagingArea($this->diffID);
$phid = idx($diff_info, 'phid');
if ($phid) {
$this->hitAutotargets = $this->updateAutotargets(
$phid,
$unit_result);
}
$this->updateLintDiffProperty();
$this->updateUnitDiffProperty();
$this->updateLocalDiffProperty();
$this->updateOntoDiffProperty();
$this->resolveDiffPropertyUpdates();
$output_json = $this->getArgument('json');
if ($this->shouldOnlyCreateDiff()) {
if (!$output_json) {
echo phutil_console_format(
"%s\n **%s** __%s__\n\n",
pht('Created a new Differential diff:'),
pht('Diff URI:'),
$diff_info['uri']);
} else {
$human = ob_get_clean();
echo json_encode(array(
'diffURI' => $diff_info['uri'],
'diffID' => $this->getDiffID(),
'human' => $human,
))."\n";
ob_start();
}
if ($this->shouldOpenCreatedObjectsInBrowser()) {
$this->openURIsInBrowser(array($diff_info['uri']));
}
} else {
$is_draft = $this->getArgument('draft');
$revision['diffid'] = $this->getDiffID();
if ($commit_message->getRevisionID()) {
if ($is_draft) {
// TODO: In at least some cases, we could raise this earlier in the
// workflow to save users some time before the workflow aborts.
if ($this->revisionIsDraft) {
$this->writeWarn(
pht('ALREADY A DRAFT'),
pht(
'You are updating a revision ("%s") with the "--draft" flag, '.
'but this revision is already a draft. You only need to '.
'provide the "--draft" flag when creating a revision. Draft '.
'revisions are not published until you explicitly request '.
'review from the web UI.',
$commit_message->getRevisionMonogram()));
} else {
throw new ArcanistUsageException(
pht(
'You are updating a revision ("%s") with the "--draft" flag, '.
'but this revision has already been published for review. '.
'You can not turn a revision back into a draft once it has '.
'been published.',
$commit_message->getRevisionMonogram()));
}
}
$result = $conduit->callMethodSynchronous(
'differential.updaterevision',
$revision);
foreach (array('edit-messages.json', 'update-messages.json') as $file) {
$messages = $this->readScratchJSONFile($file);
unset($messages[$revision['id']]);
$this->writeScratchJSONFile($file, $messages);
}
$result_uri = $result['uri'];
$result_id = $result['revisionid'];
echo pht('Updated an existing Differential revision:')."\n";
} else {
// NOTE: We're either using "differential.revision.edit" (preferred)
// if we can, or falling back to "differential.createrevision"
// (the older way) if not.
$xactions = $this->revisionTransactions;
if ($xactions) {
$xactions[] = array(
'type' => 'update',
'value' => $diff_info['phid'],
);
if ($is_draft) {
$xactions[] = array(
'type' => 'draft',
'value' => true,
);
}
$result = $conduit->callMethodSynchronous(
'differential.revision.edit',
array(
'transactions' => $xactions,
));
$result_id = idxv($result, array('object', 'id'));
if (!$result_id) {
throw new Exception(
pht(
'Expected a revision ID to be returned by '.
'"differential.revision.edit".'));
}
// TODO: This is hacky, but we don't currently receive a URI back
// from "differential.revision.edit".
$result_uri = id(new PhutilURI($this->getConduitURI()))
->setPath('/D'.$result_id);
} else {
if ($is_draft) {
throw new ArcanistUsageException(
pht(
'You have specified "--draft", but the software version '.
'on the server is too old to support draft revisions. Omit '.
'the flag or upgrade the server software.'));
}
$revision = $this->dispatchWillCreateRevisionEvent($revision);
$result = $conduit->callMethodSynchronous(
'differential.createrevision',
$revision);
$result_uri = $result['uri'];
$result_id = $result['revisionid'];
}
$revised_message = $conduit->callMethodSynchronous(
'differential.getcommitmessage',
array(
'revision_id' => $result_id,
));
if ($this->shouldAmend()) {
$repository_api = $this->getRepositoryAPI();
if ($repository_api->supportsAmend()) {
echo pht('Updating commit message...')."\n";
$repository_api->amendCommit($revised_message);
} else {
echo pht(
'Commit message was not amended. Amending commit message is '.
'only supported in git and hg (version 2.2 or newer)');
}
}
echo pht('Created a new Differential revision:')."\n";
}
$uri = $result_uri;
echo phutil_console_format(
" **%s** __%s__\n\n",
pht('Revision URI:'),
$uri);
if ($this->shouldOpenCreatedObjectsInBrowser()) {
$this->openURIsInBrowser(array($uri));
}
}
echo pht('Included changes:')."\n";
foreach ($changes as $change) {
echo ' '.$change->renderTextSummary()."\n";
}
if ($output_json) {
ob_get_clean();
}
$this->removeScratchFile('create-message');
return 0;
}
private function runRepositoryAPISetup() {
if (!$this->requiresRepositoryAPI()) {
return;
}
$repository_api = $this->getRepositoryAPI();
$repository_api->setBaseCommitArgumentRules(
$this->getArgument('base', ''));
if ($repository_api->supportsCommitRanges()) {
$this->parseBaseCommitArgument($this->getArgument('paths'));
}
$head_commit = $this->getArgument('head');
if ($head_commit !== null) {
$repository_api->setHeadCommit($head_commit);
}
}
private function runDiffSetupBasics() {
$output_json = $this->getArgument('json');
if ($output_json) {
// TODO: We should move this to a higher-level and put an indirection
// layer between echoing stuff and stdout.
ob_start();
}
if ($this->requiresWorkingCopy()) {
$repository_api = $this->getRepositoryAPI();
if ($this->getArgument('add-all')) {
$this->setCommitMode(self::COMMIT_ENABLE);
} else if ($this->getArgument('uncommitted')) {
$this->setCommitMode(self::COMMIT_DISABLE);
} else {
$this->setCommitMode(self::COMMIT_ALLOW);
}
if ($repository_api instanceof ArcanistSubversionAPI) {
$repository_api->limitStatusToPaths($this->getArgument('paths'));
}
if (!$this->getArgument('head')) {
$this->requireCleanWorkingCopy();
}
}
$this->dispatchEvent(
ArcanistEventType::TYPE_DIFF_DIDCOLLECTCHANGES,
array());
}
private function buildRevisionFromCommitMessage(
ArcanistDifferentialCommitMessage $message) {
$conduit = $this->getConduit();
$revision_id = $message->getRevisionID();
$revision = array(
'fields' => $message->getFields(),
);
$xactions = $message->getTransactions();
if ($revision_id) {
// With '--verbatim', pass the (possibly modified) local fields. This
// allows the user to edit some fields (like "title" and "summary")
// locally without '--edit' and have changes automatically synchronized.
// Without '--verbatim', we do not update the revision to reflect local
// commit message changes.
if ($this->getArgument('verbatim')) {
$use_fields = $message->getFields();
} else {
$use_fields = array();
}
$should_edit = $this->getArgument('edit');
$edit_messages = $this->readScratchJSONFile('edit-messages.json');
$remote_corpus = idx($edit_messages, $revision_id);
if (!$should_edit || !$remote_corpus || $use_fields) {
if ($this->commitMessageFromRevision) {
$remote_corpus = $this->commitMessageFromRevision;
} else {
$remote_corpus = $conduit->callMethodSynchronous(
'differential.getcommitmessage',
array(
'revision_id' => $revision_id,
'edit' => 'edit',
'fields' => $use_fields,
));
}
}
if ($should_edit) {
$edited = $this->newInteractiveEditor($remote_corpus)
->setName('differential-edit-revision-info')
->setTaskMessage(pht(
'Update the details for a revision, then save and exit.'))
->editInteractively();
if ($edited != $remote_corpus) {
$remote_corpus = $edited;
$edit_messages[$revision_id] = $remote_corpus;
$this->writeScratchJSONFile('edit-messages.json', $edit_messages);
}
}
if ($this->commitMessageFromRevision == $remote_corpus) {
$new_message = $message;
} else {
$remote_corpus = ArcanistCommentRemover::removeComments(
$remote_corpus);
$new_message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$remote_corpus);
$new_message->pullDataFromConduit($conduit);
}
$revision['fields'] = $new_message->getFields();
$xactions = $new_message->getTransactions();
$revision['id'] = $revision_id;
$this->revisionID = $revision_id;
$revision['message'] = $this->getArgument('message');
if ($revision['message'] === null) {
$update_messages = $this->readScratchJSONFile('update-messages.json');
$update_messages[$revision_id] = $this->getUpdateMessage(
$revision['fields'],
idx($update_messages, $revision_id));
$revision['message'] = ArcanistCommentRemover::removeComments(
$update_messages[$revision_id]);
if (!strlen(trim($revision['message']))) {
throw new ArcanistUserAbortException();
}
$this->writeScratchJSONFile('update-messages.json', $update_messages);
}
}
$this->revisionTransactions = $xactions;
return $revision;
}
protected function shouldOnlyCreateDiff() {
if ($this->getArgument('create')) {
return false;
}
if ($this->getArgument('update')) {
return false;
}
if ($this->isRawDiffSource()) {
return true;
}
return $this->getArgument('only');
}
private function generateAffectedPaths() {
if ($this->isRawDiffSource()) {
return array();
}
$repository_api = $this->getRepositoryAPI();
if ($repository_api instanceof ArcanistSubversionAPI) {
$file_list = new FileList($this->getArgument('paths', array()));
$paths = $repository_api->getSVNStatus($externals = true);
foreach ($paths as $path => $mask) {
if (!$file_list->contains($repository_api->getPath($path), true)) {
unset($paths[$path]);
}
}
$warn_externals = array();
foreach ($paths as $path => $mask) {
$any_mod = ($mask & ArcanistRepositoryAPI::FLAG_ADDED) ||
($mask & ArcanistRepositoryAPI::FLAG_MODIFIED) ||
($mask & ArcanistRepositoryAPI::FLAG_DELETED);
if ($mask & ArcanistRepositoryAPI::FLAG_EXTERNALS) {
unset($paths[$path]);
if ($any_mod) {
$warn_externals[] = $path;
}
}
}
if ($warn_externals && !$this->hasWarnedExternals) {
echo phutil_console_format(
"%s\n\n%s\n\n",
pht(
"The working copy includes changes to '%s' paths. These ".
"changes will not be included in the diff because SVN can not ".
"commit 'svn:externals' changes alongside normal changes.",
'svn:externals'),
pht(
"Modified '%s' files:",
'svn:externals'),
phutil_console_wrap(implode("\n", $warn_externals), 8));
$prompt = pht('Generate a diff (with just local changes) anyway?');
if (!phutil_console_confirm($prompt)) {
throw new ArcanistUserAbortException();
} else {
$this->hasWarnedExternals = true;
}
}
} else {
$paths = $repository_api->getWorkingCopyStatus();
}
foreach ($paths as $path => $mask) {
if ($mask & ArcanistRepositoryAPI::FLAG_UNTRACKED) {
unset($paths[$path]);
}
}
return $paths;
}
protected function generateChanges() {
$parser = $this->newDiffParser();
$is_raw = $this->isRawDiffSource();
if ($is_raw) {
if ($this->getArgument('raw')) {
PhutilSystem::writeStderr(
tsprintf(
"%s\n",
pht('Reading diff from stdin...')));
$raw_diff = file_get_contents('php://stdin');
} else if ($this->getArgument('raw-command')) {
list($raw_diff) = execx('%C', $this->getArgument('raw-command'));
} else {
throw new Exception(pht('Unknown raw diff source.'));
}
$changes = $parser->parseDiff($raw_diff);
foreach ($changes as $key => $change) {
// Remove "message" changes, e.g. from "git show".
if ($change->getType() == ArcanistDiffChangeType::TYPE_MESSAGE) {
unset($changes[$key]);
}
}
return $changes;
}
$repository_api = $this->getRepositoryAPI();
if ($repository_api instanceof ArcanistSubversionAPI) {
$paths = $this->generateAffectedPaths();
$this->primeSubversionWorkingCopyData($paths);
// Check to make sure the user is diffing from a consistent base revision.
// This is mostly just an abuse sanity check because it's silly to do this
// and makes the code more difficult to effectively review, but it also
// affects patches and makes them nonportable.
$bases = $repository_api->getSVNBaseRevisions();
// Remove all files with baserev "0"; these files are new.
foreach ($bases as $path => $baserev) {
if ($bases[$path] <= 0) {
unset($bases[$path]);
}
}
if ($bases) {
$rev = reset($bases);
$revlist = array();
foreach ($bases as $path => $baserev) {
$revlist[] = ' '.pht('Revision %s, %s', $baserev, $path);
}
$revlist = implode("\n", $revlist);
foreach ($bases as $path => $baserev) {
if ($baserev !== $rev) {
throw new ArcanistUsageException(
pht(
"Base revisions of changed paths are mismatched. Update all ".
"paths to the same base revision before creating a diff: ".
"\n\n%s",
$revlist));
}
}
// If you have a change which affects several files, all of which are
// at a consistent base revision, treat that revision as the effective
// base revision. The use case here is that you made a change to some
// file, which updates it to HEAD, but want to be able to change it
// again without updating the entire working copy. This is a little
// sketchy but it arises in Facebook Ops workflows with config files and
// doesn't have any real material tradeoffs (e.g., these patches are
// perfectly applyable).
$repository_api->overrideSVNBaseRevisionNumber($rev);
}
$changes = $parser->parseSubversionDiff(
$repository_api,
$paths);
} else if ($repository_api instanceof ArcanistGitAPI) {
$diff = $repository_api->getFullGitDiff(
$repository_api->getBaseCommit(),
$repository_api->getHeadCommit());
if (!strlen($diff)) {
throw new ArcanistUsageException(
pht('No changes found. (Did you specify the wrong commit range?)'));
}
$changes = $parser->parseDiff($diff);
} else if ($repository_api instanceof ArcanistMercurialAPI) {
$diff = $repository_api->getFullMercurialDiff();
if (!strlen($diff)) {
throw new ArcanistUsageException(
pht('No changes found. (Did you specify the wrong commit range?)'));
}
$changes = $parser->parseDiff($diff);
} else {
throw new Exception(pht('Repository API is not supported.'));
}
$limit = 1024 * 1024 * 4;
foreach ($changes as $change) {
$size = 0;
foreach ($change->getHunks() as $hunk) {
$size += strlen($hunk->getCorpus());
}
if ($size > $limit) {
$byte_warning = pht(
"Diff for '%s' with context is %s bytes in length. ".
"Generally, source changes should not be this large.",
$change->getCurrentPath(),
new PhutilNumber($size));
if ($repository_api instanceof ArcanistSubversionAPI) {
throw new ArcanistUsageException(
$byte_warning.' '.
pht(
"If the file is not a text file, mark it as binary with:".
"\n\n $ %s\n",
'svn propset svn:mime-type application/octet-stream <filename>'));
} else {
$confirm = $byte_warning.' '.pht(
"If the file is not a text file, you can mark it 'binary'. ".
"Mark this file as 'binary' and continue?");
if (phutil_console_confirm($confirm)) {
$change->convertToBinaryChange($repository_api);
} else {
throw new ArcanistUsageException(
pht('Aborted generation of gigantic diff.'));
}
}
}
}
$utf8_problems = array();
foreach ($changes as $change) {
foreach ($change->getHunks() as $hunk) {
$corpus = $hunk->getCorpus();
if (!phutil_is_utf8($corpus)) {
// If this corpus is heuristically binary, don't try to convert it.
// mb_check_encoding() and mb_convert_encoding() are both very very
// liberal about what they're willing to process.
$is_binary = ArcanistDiffUtils::isHeuristicBinaryFile($corpus);
if (!$is_binary) {
try {
$try_encoding = $this->getRepositoryEncoding();
} catch (ConduitClientException $e) {
if ($e->getErrorCode() == 'ERR-BAD-ARCANIST-PROJECT') {
echo phutil_console_wrap(
pht('Lookup of encoding in project failed: %s',
$e->getMessage())."\n");
} else {
throw $e;
}
}
if ($try_encoding) {
$corpus = phutil_utf8_convert($corpus, 'UTF-8', $try_encoding);
$name = $change->getCurrentPath();
if (phutil_is_utf8($corpus)) {
$this->writeStatusMessage(
pht(
"Converted a '%s' hunk from '%s' to UTF-8.\n",
$name,
$try_encoding));
$hunk->setCorpus($corpus);
continue;
}
}
}
$utf8_problems[] = $change;
break;
}
}
}
// If there are non-binary files which aren't valid UTF-8, warn the user
// and treat them as binary changes. See D327 for discussion of why Arcanist
// has this behavior.
if ($utf8_problems) {
$utf8_warning =
sprintf(
"%s\n\n%s\n\n %s\n",
pht(
'This diff includes %s file(s) which are not valid UTF-8 (they '.
'contain invalid byte sequences). You can either stop this '.
'workflow and fix these files, or continue. If you continue, '.
'these files will be marked as binary.',
phutil_count($utf8_problems)),
pht(
"You can learn more about how this software handles character ".
"encodings (and how to configure encoding settings and detect and ".
"correct encoding problems) by reading 'User Guide: UTF-8 and ".
"Character Encoding' in the documentation."),
pht(
'%s AFFECTED FILE(S)',
phutil_count($utf8_problems)));
$confirm = pht(
'Do you want to mark these %s file(s) as binary and continue?',
phutil_count($utf8_problems));
echo phutil_console_format(
"**%s**\n",
pht('Invalid Content Encoding (Non-UTF8)'));
echo phutil_console_wrap($utf8_warning);
$file_list = mpull($utf8_problems, 'getCurrentPath');
$file_list = ' '.implode("\n ", $file_list);
echo $file_list;
if (!phutil_console_confirm($confirm, $default_no = false)) {
throw new ArcanistUsageException(pht('Aborted workflow to fix UTF-8.'));
} else {
foreach ($utf8_problems as $change) {
$change->convertToBinaryChange($repository_api);
}
}
}
$this->uploadFilesForChanges($changes);
return $changes;
}
private function getGitParentLogInfo() {
$info = array(
'parent' => null,
'base_revision' => null,
'base_path' => null,
'uuid' => null,
);
$repository_api = $this->getRepositoryAPI();
$parser = $this->newDiffParser();
$history_messages = $repository_api->getGitHistoryLog();
if (!$history_messages) {
// This can occur on the initial commit.
return $info;
}
$history_messages = $parser->parseDiff($history_messages);
foreach ($history_messages as $key => $change) {
try {
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$change->getMetadata('message'));
if ($message->getRevisionID() && $info['parent'] === null) {
$info['parent'] = $message->getRevisionID();
}
if ($message->getGitSVNBaseRevision() &&
$info['base_revision'] === null) {
$info['base_revision'] = $message->getGitSVNBaseRevision();
$info['base_path'] = $message->getGitSVNBasePath();
}
if ($message->getGitSVNUUID()) {
$info['uuid'] = $message->getGitSVNUUID();
}
if ($info['parent'] && $info['base_revision']) {
break;
}
} catch (ArcanistDifferentialCommitMessageParserException $ex) {
// Ignore.
} catch (ArcanistUsageException $ex) {
// Ignore an invalid Differential Revision field in the parent commit
}
}
return $info;
}
protected function primeSubversionWorkingCopyData($paths) {
$repository_api = $this->getRepositoryAPI();
$futures = array();
$targets = array();
foreach ($paths as $path => $mask) {
$futures[] = $repository_api->buildDiffFuture($path);
$targets[] = array('command' => 'diff', 'path' => $path);
$futures[] = $repository_api->buildInfoFuture($path);
$targets[] = array('command' => 'info', 'path' => $path);
}
$futures = id(new FutureIterator($futures))
->limit(8);
foreach ($futures as $key => $future) {
$target = $targets[$key];
if ($target['command'] == 'diff') {
$repository_api->primeSVNDiffResult(
$target['path'],
$future->resolve());
} else {
$repository_api->primeSVNInfoResult(
$target['path'],
$future->resolve());
}
}
}
private function shouldAmend() {
if ($this->isRawDiffSource()) {
return false;
}
if ($this->getArgument('no-amend')) {
return false;
}
if ($this->getArgument('head') !== null) {
return false;
}
// Run this last: with --raw or --raw-command, we won't have a repository
// API.
if ($this->isHistoryImmutable()) {
return false;
}
return true;
}
/* -( Lint and Unit Tests )------------------------------------------------ */
/**
* @task lintunit
*/
private function runLintUnit() {
$lint_result = $this->runLint();
$unit_result = $this->runUnit();
return array(
'lintResult' => $lint_result,
'unresolvedLint' => $this->unresolvedLint,
'unitResult' => $unit_result,
'testResults' => $this->testResults,
);
}
/**
* @task lintunit
*/
private function runLint() {
if ($this->getArgument('nolint') ||
$this->isRawDiffSource() ||
$this->getArgument('head')) {
return ArcanistLintWorkflow::RESULT_SKIP;
}
$repository_api = $this->getRepositoryAPI();
$this->console->writeOut("%s\n", pht('Linting...'));
try {
$argv = $this->getPassthruArgumentsAsArgv('lint');
if ($repository_api->supportsCommitRanges()) {
$argv[] = '--rev';
$argv[] = $repository_api->getBaseCommit();
}
$lint_workflow = $this->buildChildWorkflow('lint', $argv);
if ($this->shouldAmend()) {
// TODO: We should offer to create a checkpoint commit.
$lint_workflow->setShouldAmendChanges(true);
}
$lint_result = $lint_workflow->run();
switch ($lint_result) {
case ArcanistLintWorkflow::RESULT_OKAY:
$this->console->writeOut(
"<bg:green>** %s **</bg> %s\n",
pht('LINT OKAY'),
pht('No lint problems.'));
break;
case ArcanistLintWorkflow::RESULT_WARNINGS:
$this->console->writeOut(
"<bg:yellow>** %s **</bg> %s\n",
pht('LINT MESSAGES'),
pht('Lint issued unresolved warnings.'));
break;
case ArcanistLintWorkflow::RESULT_ERRORS:
$this->console->writeOut(
"<bg:red>** %s **</bg> %s\n",
pht('LINT ERRORS'),
pht('Lint raised errors!'));
break;
}
$this->unresolvedLint = array();
foreach ($lint_workflow->getUnresolvedMessages() as $message) {
$this->unresolvedLint[] = $message->toDictionary();
}
return $lint_result;
} catch (ArcanistNoEngineException $ex) {
$this->console->writeOut(
"%s\n",
pht('No lint engine configured for this project.'));
} catch (ArcanistNoEffectException $ex) {
$this->console->writeOut("%s\n", $ex->getMessage());
}
return null;
}
/**
* @task lintunit
*/
private function runUnit() {
if ($this->getArgument('nounit') ||
$this->isRawDiffSource() ||
$this->getArgument('head')) {
return ArcanistUnitWorkflow::RESULT_SKIP;
}
$repository_api = $this->getRepositoryAPI();
$this->console->writeOut("%s\n", pht('Running unit tests...'));
try {
$argv = $this->getPassthruArgumentsAsArgv('unit');
if ($repository_api->supportsCommitRanges()) {
$argv[] = '--rev';
$argv[] = $repository_api->getBaseCommit();
}
$unit_workflow = $this->buildChildWorkflow('unit', $argv);
$unit_result = $unit_workflow->run();
switch ($unit_result) {
case ArcanistUnitWorkflow::RESULT_OKAY:
$this->console->writeOut(
"<bg:green>** %s **</bg> %s\n",
pht('UNIT OKAY'),
pht('No unit test failures.'));
break;
case ArcanistUnitWorkflow::RESULT_UNSOUND:
$continue = phutil_console_confirm(
pht(
'Unit test results included failures, but all failing tests '.
'are known to be unsound. Ignore unsound test failures?'));
if (!$continue) {
throw new ArcanistUserAbortException();
}
echo phutil_console_format(
"<bg:yellow>** %s **</bg> %s\n",
pht('UNIT UNSOUND'),
pht(
'Unit testing raised errors, but all '.
'failing tests are unsound.'));
break;
case ArcanistUnitWorkflow::RESULT_FAIL:
$this->console->writeOut(
"<bg:red>** %s **</bg> %s\n",
pht('UNIT ERRORS'),
pht('Unit testing raised errors!'));
break;
}
$this->testResults = array();
foreach ($unit_workflow->getTestResults() as $test) {
$this->testResults[] = $test->toDictionary();
}
return $unit_result;
} catch (ArcanistNoEngineException $ex) {
$this->console->writeOut(
"%s\n",
pht('No unit test engine is configured for this project.'));
} catch (ArcanistNoEffectException $ex) {
$this->console->writeOut("%s\n", $ex->getMessage());
}
return null;
}
public function getTestResults() {
return $this->testResults;
}
/* -( Commit and Update Messages )----------------------------------------- */
/**
* @task message
*/
private function buildCommitMessage() {
if ($this->getArgument('only')) {
return null;
}
$is_create = $this->getArgument('create');
$is_update = $this->getArgument('update');
$is_raw = $this->isRawDiffSource();
$is_verbatim = $this->getArgument('verbatim');
if ($is_verbatim) {
return $this->getCommitMessageFromUser();
}
if (!$is_raw && !$is_create && !$is_update) {
$repository_api = $this->getRepositoryAPI();
$revisions = $repository_api->loadWorkingCopyDifferentialRevisions(
$this->getConduit(),
array(
'authors' => array($this->getUserPHID()),
'status' => 'status-open',
));
if (!$revisions) {
$is_create = true;
} else if (count($revisions) == 1) {
$revision = head($revisions);
$is_update = $revision['id'];
} else {
throw new ArcanistUsageException(
pht(
"There are several revisions which match the working copy:\n\n%s\n".
"Use '%s' to choose one, or '%s' to create a new revision.",
$this->renderRevisionList($revisions),
'--update',
'--create'));
}
}
$message = null;
if ($is_create) {
$message_file = $this->getArgument('message-file');
if ($message_file) {
return $this->getCommitMessageFromFile($message_file);
} else {
return $this->getCommitMessageFromUser();
}
} else if ($is_update) {
$revision_id = $this->normalizeRevisionID($is_update);
if (!is_numeric($revision_id)) {
throw new ArcanistUsageException(
pht(
'Parameter to %s must be a Differential Revision number.',
'--update'));
}
return $this->getCommitMessageFromRevision($revision_id);
} else {
// This is --raw without enough info to create a revision, so force just
// a diff.
return null;
}
}
/**
* @task message
*/
private function getCommitMessageFromUser() {
$conduit = $this->getConduit();
$template = null;
if (!$this->getArgument('verbatim')) {
$saved = $this->readScratchFile('create-message');
if ($saved) {
$where = $this->getReadableScratchFilePath('create-message');
$preview = explode("\n", $saved);
$preview = array_shift($preview);
$preview = trim($preview);
$preview = id(new PhutilUTF8StringTruncator())
->setMaximumGlyphs(64)
->truncateString($preview);
if ($preview) {
$preview = pht('Message begins:')."\n\n {$preview}\n\n";
} else {
$preview = null;
}
echo pht(
"You have a saved revision message in '%s'.\n%s".
"You can use this message, or discard it.",
$where,
$preview);
$use = phutil_console_confirm(
pht('Do you want to use this message?'),
$default_no = false);
if ($use) {
$template = $saved;
} else {
$this->removeScratchFile('create-message');
}
}
}
$template_is_default = false;
$notes = array();
$included = array();
list($fields, $notes, $included_commits) = $this->getDefaultCreateFields();
if ($template) {
$fields = array();
$notes = array();
} else {
if (!$fields) {
$template_is_default = true;
}
if ($notes) {
$commit = head($this->getRepositoryAPI()->getLocalCommitInformation());
$template = $commit['message'];
} else {
$template = $conduit->callMethodSynchronous(
'differential.getcommitmessage',
array(
'revision_id' => null,
'edit' => 'create',
'fields' => $fields,
));
}
}
$old_message = $template;
$included = array();
if ($included_commits) {
foreach ($included_commits as $commit) {
$included[] = ' '.$commit;
}
if (!$this->isRawDiffSource()) {
$message = pht(
'Included commits in branch %s:',
$this->getRepositoryAPI()->getBranchName());
} else {
$message = pht('Included commits:');
}
$included = array_merge(
array(
'',
$message,
'',
),
$included);
}
$issues = array_merge(
array(
pht('NEW DIFFERENTIAL REVISION'),
pht('Describe the changes in this new revision.'),
),
$included,
array(
'',
pht(
'arc could not identify any existing revision in your working copy.'),
pht('If you intended to update an existing revision, use:'),
'',
' $ arc diff --update <revision>',
));
if ($notes) {
$issues = array_merge($issues, array(''), $notes);
}
$done = false;
$first = true;
while (!$done) {
$template = rtrim($template, "\r\n")."\n\n";
foreach ($issues as $issue) {
$template .= rtrim('# '.$issue)."\n";
}
$template .= "\n";
if ($first && $this->getArgument('verbatim') && !$template_is_default) {
$new_template = $template;
} else {
$new_template = $this->newInteractiveEditor($template)
->setName('new-commit')
->setTaskMessage(pht(
'Provide the details for a new revision, then save and exit.'))
->editInteractively();
}
$first = false;
if ($template_is_default && ($new_template == $template)) {
throw new ArcanistUsageException(pht('Template not edited.'));
}
$template = ArcanistCommentRemover::removeComments($new_template);
// With --raw-command, we may not have a repository API.
if ($this->hasRepositoryAPI()) {
$repository_api = $this->getRepositoryAPI();
// special check for whether to amend here. optimizes a common git
// workflow. we can't do this for mercurial because the mq extension
// is popular and incompatible with hg commit --amend ; see T2011.
$should_amend = (count($included_commits) == 1 &&
$repository_api instanceof ArcanistGitAPI &&
$this->shouldAmend());
} else {
$should_amend = false;
}
if ($should_amend) {
$wrote = (rtrim($old_message) != rtrim($template));
if ($wrote) {
$repository_api->amendCommit($template);
$where = pht('commit message');
}
} else {
$wrote = $this->writeScratchFile('create-message', $template);
$where = "'".$this->getReadableScratchFilePath('create-message')."'";
}
try {
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus(
$template);
$message->pullDataFromConduit($conduit);
$this->validateCommitMessage($message);
$done = true;
} catch (ArcanistDifferentialCommitMessageParserException $ex) {
echo pht('Commit message has errors:')."\n\n";
$issues = array(pht('Resolve these errors:'));
foreach ($ex->getParserErrors() as $error) {
echo phutil_console_wrap("- ".$error."\n", 6);
$issues[] = ' - '.$error;
}
echo "\n";
echo pht('You must resolve these errors to continue.');
$again = phutil_console_confirm(
pht('Do you want to edit the message?'),
$default_no = false);
if ($again) {
// Keep going.
} else {
$saved = null;
if ($wrote) {
$saved = pht('A copy was saved to %s.', $where);
}
throw new ArcanistUsageException(
pht('Message has unresolved errors.')." {$saved}");
}
} catch (Exception $ex) {
if ($wrote) {
echo phutil_console_wrap(pht('(Message saved to %s.)', $where)."\n");
}
throw $ex;
}
}
return $message;
}
/**
* @task message
*/
private function getCommitMessageFromFile($file) {
$conduit = $this->getConduit();
$data = Filesystem::readFile($file);
$message = ArcanistDifferentialCommitMessage::newFromRawCorpus($data);
$message->pullDataFromConduit($conduit);
$this->validateCommitMessage($message);
return $message;
}
/**
* @task message
*/
private function getCommitMessageFromRevision($revision_id) {
$id = $revision_id;
$revision = $this->getConduit()->callMethodSynchronous(
'differential.query',
array(
'ids' => array($id),
));
$revision = head($revision);
if (!$revision) {
throw new ArcanistUsageException(
pht(
"Revision '%s' does not exist!",
$revision_id));
}
$this->checkRevisionOwnership($revision);
// TODO: Save this status to improve a prompt later. See PHI458. This is
// extra awful until we move to "differential.revision.search" because
// the "differential.query" method doesn't return a real draft status for
// compatibility.
$this->revisionIsDraft = (idx($revision, 'statusName') === 'Draft');
$message = $this->getConduit()->callMethodSynchronous(
'differential.getcommitmessage',
array(
'revision_id' => $id,
'edit' => false,
));
$this->commitMessageFromRevision = $message;
$obj = ArcanistDifferentialCommitMessage::newFromRawCorpus($message);
$obj->pullDataFromConduit($this->getConduit());
return $obj;
}
/**
* @task message
*/
private function validateCommitMessage(
ArcanistDifferentialCommitMessage $message) {
$futures = array();
$revision_id = $message->getRevisionID();
if ($revision_id) {
$futures['revision'] = $this->getConduit()->callMethod(
'differential.query',
array(
'ids' => array($revision_id),
));
}
$reviewers = $message->getFieldValue('reviewerPHIDs');
if ($reviewers) {
$futures['reviewers'] = $this->getConduit()->callMethod(
'user.query',
array(
'phids' => $reviewers,
));
}
foreach (new FutureIterator($futures) as $key => $future) {
$result = $future->resolve();
switch ($key) {
case 'revision':
if (empty($result)) {
throw new ArcanistUsageException(
pht(
'There is no revision %s.',
"D{$revision_id}"));
}
$this->checkRevisionOwnership(head($result));
break;
case 'reviewers':
$away = array();
foreach ($result as $user) {
if (idx($user, 'currentStatus') != 'away') {
continue;
}
$username = $user['userName'];
$real_name = $user['realName'];
if (strlen($real_name)) {
$name = pht('%s (%s)', $username, $real_name);
} else {
$name = pht('%s', $username);
}
$away[] = array(
'name' => $name,
'until' => $user['currentStatusUntil'],
);
}
if ($away) {
if (count($away) == count($reviewers)) {
$earliest_return = min(ipull($away, 'until'));
$message = pht(
'All reviewers are away until %s:',
date('l, M j Y', $earliest_return));
} else {
$message = pht('Some reviewers are currently away:');
}
echo tsprintf(
"%s\n\n",
$message);
$list = id(new PhutilConsoleList());
foreach ($away as $spec) {
$list->addItem(
pht(
'%s (until %s)',
$spec['name'],
date('l, M j Y', $spec['until'])));
}
echo tsprintf(
'%B',
$list->drawConsoleString());
$confirm = pht('Continue even though reviewers are unavailable?');
if (!phutil_console_confirm($confirm)) {
throw new ArcanistUsageException(
pht('Specify available reviewers and retry.'));
}
}
break;
}
}
}
/**
* @task message
*/
private function getUpdateMessage(array $fields, $template = '') {
if ($this->getArgument('raw')) {
throw new ArcanistUsageException(
pht(
"When using '%s' to update a revision, specify an update message ".
"with '%s'. (Normally, we'd launch an editor to ask you for a ".
"message, but can not do that because stdin is the diff source.)",
'--raw',
'--message'));
}
// When updating a revision using git without specifying '--message', try
// to prefill with the message in HEAD if it isn't a template message. The
// idea is that if you do:
//
// $ git commit -a -m 'fix some junk'
// $ arc diff
//
// ...you shouldn't have to retype the update message. Similar things apply
// to Mercurial.
if ($template == '') {
$comments = $this->getDefaultUpdateMessage();
$comments = phutil_string_cast($comments);
$comments = rtrim($comments);
$template = sprintf(
"%s\n\n# %s\n#\n# %s\n# %s\n#\n# %s\n# $ %s\n\n",
$comments,
pht(
'Updating %s: %s',
"D{$fields['revisionID']}",
$fields['title']),
pht(
'Enter a brief description of the changes included in this update.'),
pht('The first line is used as subject, next lines as comment.'),
pht('If you intended to create a new revision, use:'),
'arc diff --create');
}
$comments = $this->newInteractiveEditor($template)
->setName('differential-update-comments')
->setTaskMessage(pht(
'Update the revision comments, then save and exit.'))
->editInteractively();
return $comments;
}
private function getDefaultCreateFields() {
$result = array(array(), array(), array());
if ($this->isRawDiffSource()) {
return $result;
}
$repository_api = $this->getRepositoryAPI();
$local = $repository_api->getLocalCommitInformation();
if ($local) {
$result = $this->parseCommitMessagesIntoFields($local);
if ($this->getArgument('create')) {
unset($result[0]['revisionID']);
}
}
$result[0] = $this->dispatchWillBuildEvent($result[0]);
return $result;
}
/**
* Convert a list of commits from `getLocalCommitInformation()` into
* a format usable by arc to create a new diff. Specifically, we emit:
*
* - A dictionary of commit message fields.
* - A list of errors encountered while parsing the messages.
* - A human-readable list of the commits themselves.
*
* For example, if the user runs "arc diff HEAD^^^" and selects a diff range
* which includes several diffs, we attempt to merge them somewhat
* intelligently into a single message, because we can only send one
* "Summary:", "Reviewers:", etc., field to Differential. We also return
* errors (e.g., if the user typed a reviewer name incorrectly) and a
* summary of the commits themselves.
*
- * @param dict Local commit information.
+ * @param dict $local Local commit information.
* @return list Complex output, see summary.
* @task message
*/
private function parseCommitMessagesIntoFields(array $local) {
$conduit = $this->getConduit();
$local = ipull($local, null, 'commit');
// If the user provided "--reviewers" or "--ccs", add a faux message to
// the list with the implied fields.
$faux_message = array();
if ($this->getArgument('reviewers')) {
$faux_message[] = pht('Reviewers: %s', $this->getArgument('reviewers'));
}
if ($this->getArgument('cc')) {
$faux_message[] = pht('CC: %s', $this->getArgument('cc'));
}
// NOTE: For now, this isn't a real field, so it just ends up as the first
// part of the summary.
$depends_ref = $this->getDependsOnRevisionRef();
if ($depends_ref) {
$faux_message[] = pht(
'Depends on %s. ',
$depends_ref->getMonogram());
}
// See T12069. After T10312, the first line of a message is always parsed
// as a title. Add a placeholder so "Reviewers" and "CC" are never the
// first line.
$placeholder_title = pht('<placeholder>');
if ($faux_message) {
array_unshift($faux_message, $placeholder_title);
$faux_message = implode("\n\n", $faux_message);
$local = array(
'(Flags) ' => array(
'message' => $faux_message,
'summary' => pht('Command-Line Flags'),
),
) + $local;
}
// Build a human-readable list of the commits, so we can show the user which
// commits are included in the diff.
$included = array();
foreach ($local as $hash => $info) {
$included[] = substr($hash, 0, 12).' '.$info['summary'];
}
// Parse all of the messages into fields.
$messages = array();
foreach ($local as $hash => $info) {
$text = $info['message'];
$obj = ArcanistDifferentialCommitMessage::newFromRawCorpus($text);
$messages[$hash] = $obj;
}
$notes = array();
$fields = array();
foreach ($messages as $hash => $message) {
try {
$message->pullDataFromConduit($conduit, $partial = true);
$fields[$hash] = $message->getFields();
} catch (ArcanistDifferentialCommitMessageParserException $ex) {
if ($this->getArgument('verbatim')) {
// In verbatim mode, just bail when we hit an error. The user can
// rerun without --verbatim if they want to fix it manually. Most
// users will probably `git commit --amend` instead.
throw $ex;
}
$fields[$hash] = $message->getFields();
$frev = substr($hash, 0, 12);
$notes[] = pht(
'NOTE: commit %s could not be completely parsed:',
$frev);
foreach ($ex->getParserErrors() as $error) {
$notes[] = " - {$error}";
}
}
}
// Merge commit message fields. We do this somewhat-intelligently so that
// multiple "Reviewers" or "CC" fields will merge into the concatenation
// of all values.
// We have special parsing rules for 'title' because we can't merge
// multiple titles, and one-line commit messages like "fix stuff" will
// parse as titles. Instead, pick the first title we encounter. When we
// encounter subsequent titles, treat them as part of the summary. Then
// we merge all the summaries together below.
$result = array();
// Process fields in oldest-first order, so earlier commits get to set the
// title of record and reviewers/ccs are listed in chronological order.
$fields = array_reverse($fields);
foreach ($fields as $hash => $dict) {
$title = idx($dict, 'title');
if (!strlen($title)) {
continue;
}
if ($title === $placeholder_title) {
continue;
}
if (!isset($result['title'])) {
// We don't have a title yet, so use this one.
$result['title'] = $title;
} else {
// We already have a title, so merge this new title into the summary.
$summary = idx($dict, 'summary');
if ($summary) {
$summary = $title."\n\n".$summary;
} else {
$summary = $title;
}
$fields[$hash]['summary'] = $summary;
}
}
// Now, merge all the other fields in a general sort of way.
foreach ($fields as $hash => $dict) {
foreach ($dict as $key => $value) {
if ($key == 'title') {
// This has been handled above, and either assigned directly or
// merged into the summary.
continue;
}
if ($value === null) {
continue;
}
if (is_array($value)) {
// For array values, merge the arrays, appending the new values.
// Examples are "Reviewers" and "Cc", where this produces a list of
// all users specified as reviewers.
$cur = idx($result, $key, array());
$new = array_merge($cur, $value);
$result[$key] = $new;
continue;
} else {
if (!strlen(trim($value))) {
// Ignore empty fields.
continue;
}
// For string values, append the new field to the old field with
// a blank line separating them. Examples are "Test Plan" and
// "Summary".
$cur = idx($result, $key, '');
if (strlen($cur)) {
$new = $cur."\n\n".$value;
} else {
$new = $value;
}
$result[$key] = $new;
}
}
}
return array($result, $notes, $included);
}
private function getDefaultUpdateMessage() {
if ($this->isRawDiffSource()) {
return null;
}
$repository_api = $this->getRepositoryAPI();
if ($repository_api instanceof ArcanistGitAPI) {
return $this->getGitUpdateMessage();
}
if ($repository_api instanceof ArcanistMercurialAPI) {
return $this->getMercurialUpdateMessage();
}
return null;
}
/**
* Retrieve the git messages between HEAD and the last update.
*
* @task message
*/
private function getGitUpdateMessage() {
$repository_api = $this->getRepositoryAPI();
$parser = $this->newDiffParser();
$commit_messages = $repository_api->getGitCommitLog();
$commit_messages = $parser->parseDiff($commit_messages);
if (count($commit_messages) == 1) {
// If there's only one message, assume this is an amend-based workflow and
// that using it to prefill doesn't make sense.
return null;
}
// We have more than one message, so figure out which ones are new. We
// do this by pulling the current diff and comparing commit hashes in the
// working copy with attached commit hashes. It's not super important that
// we always get this 100% right, we're just trying to do something
// reasonable.
$hashes = $this->loadActiveDiffLocalCommitHashes();
$hashes = array_fuse($hashes);
$usable = array();
foreach ($commit_messages as $message) {
$text = $message->getMetadata('message');
$parsed = ArcanistDifferentialCommitMessage::newFromRawCorpus($text);
if ($parsed->getRevisionID()) {
// If this is an amended commit message with a revision ID, it's
// certainly not new. Stop marking commits as usable and break out.
break;
}
if (isset($hashes[$message->getCommitHash()])) {
// If this commit is currently part of the diff, stop using commit
// messages, since anything older than this isn't new.
break;
}
// Otherwise, this looks new, so it's a usable commit message.
$usable[] = $text;
}
if (!$usable) {
// No new commit messages, so we don't have anywhere to start from.
return null;
}
return $this->formatUsableLogs($usable);
}
/**
* Retrieve the hg messages between tip and the last update.
*
* @task message
*/
private function getMercurialUpdateMessage() {
$repository_api = $this->getRepositoryAPI();
$messages = $repository_api->getCommitMessageLog();
if (count($messages) == 1) {
// If there's only one message, assume this is an amend-based workflow and
// that using it to prefill doesn't make sense.
return null;
}
$hashes = $this->loadActiveDiffLocalCommitHashes();
$hashes = array_fuse($hashes);
$usable = array();
foreach ($messages as $rev => $message) {
if (isset($hashes[$rev])) {
// If this commit is currently part of the active diff on the revision,
// stop using commit messages, since anything older than this isn't new.
break;
}
// Otherwise, this looks new, so it's a usable commit message.
$usable[] = $message;
}
if (!$usable) {
// No new commit messages, so we don't have anywhere to start from.
return null;
}
return $this->formatUsableLogs($usable);
}
/**
* Format log messages to prefill a diff update.
*
* @task message
*/
private function formatUsableLogs(array $usable) {
// Flip messages so they'll read chronologically (oldest-first) in the
// template, e.g.:
//
// - Added foobar.
// - Fixed foobar bug.
// - Documented foobar.
$usable = array_reverse($usable);
$default = array();
foreach ($usable as $message) {
// Pick the first line out of each message.
$text = trim($message);
$text = head(explode("\n", $text));
$default[] = ' - '.$text."\n";
}
return implode('', $default);
}
private function loadActiveDiffLocalCommitHashes() {
// The older "differential.querydiffs" method includes the full diff text,
// which can be very slow for large diffs. If we can, try to use
// "differential.diff.search" instead.
// We expect this to fail if the Phabricator version on the server is
// older than April 2018 (D19386), which introduced the "commits"
// attachment for "differential.revision.search".
// TODO: This can be optimized if we're able to learn the "revisionPHID"
// before we get here. See PHI1104.
try {
$revisions_raw = $this->getConduit()->callMethodSynchronous(
'differential.revision.search',
array(
'constraints' => array(
'ids' => array(
$this->revisionID,
),
),
));
$revisions = $revisions_raw['data'];
$revision = head($revisions);
if ($revision) {
$revision_phid = $revision['phid'];
$diffs_raw = $this->getConduit()->callMethodSynchronous(
'differential.diff.search',
array(
'constraints' => array(
'revisionPHIDs' => array(
$revision_phid,
),
),
'attachments' => array(
'commits' => true,
),
'limit' => 1,
));
$diffs = $diffs_raw['data'];
$diff = head($diffs);
if ($diff) {
$commits = idxv($diff, array('attachments', 'commits', 'commits'));
if ($commits !== null) {
$hashes = ipull($commits, 'identifier');
return array_values($hashes);
}
}
}
} catch (Exception $ex) {
// If any of this fails, fall back to the older method below.
}
$current_diff = $this->getConduit()->callMethodSynchronous(
'differential.querydiffs',
array(
'revisionIDs' => array($this->revisionID),
));
$current_diff = head($current_diff);
$properties = idx($current_diff, 'properties', array());
$local = idx($properties, 'local:commits', array());
$hashes = ipull($local, 'commit');
return array_values($hashes);
}
/* -( Diff Specification )------------------------------------------------- */
/**
* @task diffspec
*/
private function getLintStatus($lint_result) {
$map = array(
ArcanistLintWorkflow::RESULT_OKAY => 'okay',
ArcanistLintWorkflow::RESULT_ERRORS => 'fail',
ArcanistLintWorkflow::RESULT_WARNINGS => 'warn',
ArcanistLintWorkflow::RESULT_SKIP => 'skip',
);
return idx($map, $lint_result, 'none');
}
/**
* @task diffspec
*/
private function getUnitStatus($unit_result) {
$map = array(
ArcanistUnitWorkflow::RESULT_OKAY => 'okay',
ArcanistUnitWorkflow::RESULT_FAIL => 'fail',
ArcanistUnitWorkflow::RESULT_UNSOUND => 'warn',
ArcanistUnitWorkflow::RESULT_SKIP => 'skip',
);
return idx($map, $unit_result, 'none');
}
/**
* @task diffspec
*/
private function buildDiffSpecification() {
$base_revision = null;
$base_path = null;
$vcs = null;
$repo_uuid = null;
$parent = null;
$source_path = null;
$branch = null;
$bookmark = null;
if (!$this->isRawDiffSource()) {
$repository_api = $this->getRepositoryAPI();
$base_revision = $repository_api->getSourceControlBaseRevision();
$base_path = $repository_api->getSourceControlPath();
$vcs = $repository_api->getSourceControlSystemName();
$source_path = $repository_api->getPath();
$branch = $repository_api->getBranchName();
$repo_uuid = $repository_api->getRepositoryUUID();
if ($repository_api instanceof ArcanistGitAPI) {
$info = $this->getGitParentLogInfo();
if ($info['parent']) {
$parent = $info['parent'];
}
if ($info['base_revision']) {
$base_revision = $info['base_revision'];
}
if ($info['base_path']) {
$base_path = $info['base_path'];
}
if ($info['uuid']) {
$repo_uuid = $info['uuid'];
}
} else if ($repository_api instanceof ArcanistMercurialAPI) {
$bookmark = $repository_api->getActiveBookmark();
$svn_info = $repository_api->getSubversionInfo();
$repo_uuid = idx($svn_info, 'uuid');
$base_path = idx($svn_info, 'base_path', $base_path);
$base_revision = idx($svn_info, 'base_revision', $base_revision);
// TODO: provide parent info
}
}
$data = array(
'sourceMachine' => php_uname('n'),
'sourcePath' => $source_path,
'branch' => $branch,
'bookmark' => $bookmark,
'sourceControlSystem' => $vcs,
'sourceControlPath' => $base_path,
'sourceControlBaseRevision' => $base_revision,
'creationMethod' => 'arc',
);
if (!$this->isRawDiffSource()) {
$repository_phid = $this->getRepositoryPHID();
if ($repository_phid) {
$data['repositoryPHID'] = $repository_phid;
}
}
return $data;
}
/* -( Diff Properties )---------------------------------------------------- */
/**
* Update lint information for the diff.
*
* @return void
*
* @task diffprop
*/
private function updateLintDiffProperty() {
if (!$this->hitAutotargets) {
if ($this->unresolvedLint) {
$this->updateDiffProperty(
'arc:lint',
json_encode($this->unresolvedLint));
}
}
}
/**
* Update unit test information for the diff.
*
* @return void
*
* @task diffprop
*/
private function updateUnitDiffProperty() {
if (!$this->hitAutotargets) {
if ($this->testResults) {
$this->updateDiffProperty('arc:unit', json_encode($this->testResults));
}
}
}
/**
* Update local commit information for the diff.
*
* @task diffprop
*/
private function updateLocalDiffProperty() {
if ($this->isRawDiffSource()) {
return;
}
$local_info = $this->getRepositoryAPI()->getLocalCommitInformation();
if (!$local_info) {
return;
}
$this->updateDiffProperty('local:commits', json_encode($local_info));
}
private function updateOntoDiffProperty() {
$onto = $this->getDiffOntoTargets();
if (!$onto) {
return;
}
$this->updateDiffProperty('arc:onto', json_encode($onto));
}
private function getDiffOntoTargets() {
if ($this->isRawDiffSource()) {
return null;
}
$api = $this->getRepositoryAPI();
if (!($api instanceof ArcanistGitAPI)) {
return null;
}
// If we track an upstream branch either directly or indirectly, use that.
$branch = $api->getBranchName();
if (phutil_nonempty_string($branch)) {
$upstream_path = $api->getPathToUpstream($branch);
$remote_branch = $upstream_path->getRemoteBranchName();
if ($remote_branch !== null) {
return array(
array(
'type' => 'branch',
'name' => $remote_branch,
'kind' => 'upstream',
),
);
}
}
// If "arc.land.onto.default" is configured, use that.
$config_key = 'arc.land.onto.default';
$onto = $this->getConfigFromAnySource($config_key);
if ($onto !== null) {
return array(
array(
'type' => 'branch',
'name' => $onto,
'kind' => 'arc.land.onto.default',
),
);
}
return null;
}
/**
* Update an arbitrary diff property.
*
- * @param string Diff property name.
- * @param string Diff property value.
+ * @param string $name Diff property name.
+ * @param string $data Diff property value.
* @return void
*
* @task diffprop
*/
private function updateDiffProperty($name, $data) {
$this->diffPropertyFutures[] = $this->getConduit()->callMethod(
'differential.setdiffproperty',
array(
'diff_id' => $this->getDiffID(),
'name' => $name,
'data' => $data,
));
}
/**
* Wait for finishing all diff property updates.
*
* @return void
*
* @task diffprop
*/
private function resolveDiffPropertyUpdates() {
id(new FutureIterator($this->diffPropertyFutures))
->resolveAll();
$this->diffPropertyFutures = array();
}
private function dispatchWillCreateRevisionEvent(array $fields) {
$event = $this->dispatchEvent(
ArcanistEventType::TYPE_REVISION_WILLCREATEREVISION,
array(
'specification' => $fields,
));
return $event->getValue('specification');
}
private function dispatchWillBuildEvent(array $fields) {
$event = $this->dispatchEvent(
ArcanistEventType::TYPE_DIFF_WILLBUILDMESSAGE,
array(
'fields' => $fields,
));
return $event->getValue('fields');
}
private function checkRevisionOwnership(array $revision) {
if ($revision['authorPHID'] == $this->getUserPHID()) {
return;
}
$id = $revision['id'];
$title = $revision['title'];
$prompt = pht(
"You don't own revision %s: \"%s\". Normally, you should ".
"only update revisions you own. You can \"Commandeer\" this revision ".
"from the web interface if you want to become the owner.\n\n".
"Update this revision anyway?",
"D{$id}",
$title);
$ok = phutil_console_confirm($prompt, $default_no = true);
if (!$ok) {
throw new ArcanistUsageException(
pht('Aborted update of revision: You are not the owner.'));
}
}
/* -( File Uploads )------------------------------------------------------- */
private function uploadFilesForChanges(array $changes) {
assert_instances_of($changes, 'ArcanistDiffChange');
// Collect all the files we need to upload.
$need_upload = array();
foreach ($changes as $key => $change) {
if ($change->getFileType() != ArcanistDiffChangeType::FILE_BINARY) {
continue;
}
if ($this->getArgument('skip-binaries')) {
continue;
}
$name = basename($change->getCurrentPath());
$need_upload[] = array(
'type' => 'old',
'name' => $name,
'data' => $change->getOriginalFileData(),
'change' => $change,
);
$need_upload[] = array(
'type' => 'new',
'name' => $name,
'data' => $change->getCurrentFileData(),
'change' => $change,
);
}
if (!$need_upload) {
return;
}
// Determine mime types and file sizes. Update changes from "binary" to
// "image" if the file is an image. Set image metadata.
$type_image = ArcanistDiffChangeType::FILE_IMAGE;
foreach ($need_upload as $key => $spec) {
$change = $need_upload[$key]['change'];
if ($spec['data'] === null) {
// This covers the case where a file was added or removed; we don't
// need to upload the other half of it (e.g., the old file data for
// a file which was just added). This is distinct from an empty
// file, which we do upload.
unset($need_upload[$key]);
continue;
}
$type = $spec['type'];
$size = strlen($spec['data']);
$change->setMetadata("{$type}:file:size", $size);
$mime = $this->getFileMimeType($spec['data']);
if (preg_match('@^image/@', $mime)) {
$change->setFileType($type_image);
}
$change->setMetadata("{$type}:file:mime-type", $mime);
}
$uploader = id(new ArcanistFileUploader())
->setConduitEngine($this->getConduitEngine());
foreach ($need_upload as $key => $spec) {
$ref = id(new ArcanistFileDataRef())
->setName($spec['name'])
->setData($spec['data']);
$uploader->addFile($ref, $key);
}
$files = $uploader->uploadFiles();
$errors = false;
foreach ($files as $key => $file) {
if ($file->getErrors()) {
unset($files[$key]);
$errors = true;
echo pht(
'Failed to upload binary "%s".',
$file->getName());
}
}
if ($errors) {
$prompt = pht('Continue?');
$ok = phutil_console_confirm($prompt, $default_no = false);
if (!$ok) {
throw new ArcanistUsageException(
pht(
'Aborted due to file upload failure. You can use %s '.
'to skip binary uploads.',
'--skip-binaries'));
}
}
foreach ($files as $key => $file) {
$spec = $need_upload[$key];
$phid = $file->getPHID();
$change = $spec['change'];
$type = $spec['type'];
$change->setMetadata("{$type}:binary-phid", $phid);
echo pht('Uploaded binary data for "%s".', $file->getName())."\n";
}
echo pht('Upload complete.')."\n";
}
private function getFileMimeType($data) {
$tmp = new TempFile();
Filesystem::writeFile($tmp, $data);
return Filesystem::getMimeType($tmp);
}
private function shouldOpenCreatedObjectsInBrowser() {
return $this->getArgument('browse');
}
private function submitChangesToStagingArea($id) {
$result = $this->pushChangesToStagingArea($id);
// We'll either get a failure constant on error, or a list of pushed
// refs on success.
$ok = is_array($result);
if ($ok) {
$staging = array(
'status' => self::STAGING_PUSHED,
'refs' => $result,
);
} else {
$staging = array(
'status' => $result,
'refs' => array(),
);
}
$this->updateDiffProperty(
'arc.staging',
phutil_json_encode($staging));
}
private function pushChangesToStagingArea($id) {
if ($this->getArgument('skip-staging')) {
$this->writeInfo(
pht('SKIP STAGING'),
pht('Flag --skip-staging was specified.'));
return self::STAGING_USER_SKIP;
}
if ($this->isRawDiffSource()) {
$this->writeInfo(
pht('SKIP STAGING'),
pht('Raw changes can not be pushed to a staging area.'));
return self::STAGING_DIFF_RAW;
}
if (!$this->getRepositoryPHID()) {
$this->writeInfo(
pht('SKIP STAGING'),
pht('Unable to determine repository for this change.'));
return self::STAGING_REPOSITORY_UNKNOWN;
}
$staging = $this->getRepositoryStagingConfiguration();
if ($staging === null) {
$this->writeInfo(
pht('SKIP STAGING'),
pht('The server does not support staging areas.'));
return self::STAGING_REPOSITORY_UNAVAILABLE;
}
$supported = idx($staging, 'supported');
if (!$supported) {
$this->writeInfo(
pht('SKIP STAGING'),
pht('The server does not support staging areas for this repository.'));
return self::STAGING_REPOSITORY_UNSUPPORTED;
}
$staging_uri = idx($staging, 'uri');
if (!$staging_uri) {
$this->writeInfo(
pht('SKIP STAGING'),
pht('No staging area is configured for this repository.'));
return self::STAGING_REPOSITORY_UNCONFIGURED;
}
$api = $this->getRepositoryAPI();
if (!($api instanceof ArcanistGitAPI)) {
$this->writeInfo(
pht('SKIP STAGING'),
pht('This client version does not support staging this repository.'));
return self::STAGING_CLIENT_UNSUPPORTED;
}
$commit = $api->getHeadCommit();
$prefix = idx($staging, 'prefix', 'phabricator');
$base_tag = "refs/tags/{$prefix}/base/{$id}";
$diff_tag = "refs/tags/{$prefix}/diff/{$id}";
$this->writeOkay(
pht('PUSH STAGING'),
pht('Pushing changes to staging area...'));
$push_flags = array();
if (version_compare($api->getGitVersion(), '1.8.2', '>=')) {
$push_flags[] = '--no-verify';
}
$refs = array();
$remote = array(
'uri' => $staging_uri,
);
$is_lfs = $api->isGitLFSWorkingCopy();
// If the base commit is a real commit, we're going to push it. We don't
// use this, but pushing it to a ref reduces the amount of redundant work
// that Git does on later pushes by helping it figure out that the remote
// already has most of the history. See T10509.
// In the future, we could avoid this push if the staging area is the same
// as the main repository, or if the staging area is a virtual repository.
// In these cases, the staging area should automatically have up-to-date
// refs.
$base_commit = $api->getSourceControlBaseRevision();
if ($base_commit !== ArcanistGitAPI::GIT_MAGIC_ROOT_COMMIT) {
$refs[] = array(
'ref' => $base_tag,
'type' => 'base',
'commit' => $base_commit,
'remote' => $remote,
);
}
// We're always going to push the change itself.
$refs[] = array(
'ref' => $diff_tag,
'type' => 'diff',
'commit' => $is_lfs ? $base_commit : $commit,
'remote' => $remote,
);
$ref_list = array();
foreach ($refs as $ref) {
$ref_list[] = $ref['commit'].':'.$ref['ref'];
}
$err = phutil_passthru(
'git push %Ls -- %s %Ls',
$push_flags,
$staging_uri,
$ref_list);
if ($err) {
$this->writeWarn(
pht('STAGING FAILED'),
pht('Unable to push changes to the staging area.'));
throw new ArcanistUsageException(
pht(
'Failed to push changes to staging area. Correct the issue, or '.
'use --skip-staging to skip this step.'));
}
if ($is_lfs) {
$ref = '+'.$commit.':'.$diff_tag;
$err = phutil_passthru(
'git push -- %s %s',
$staging_uri,
$ref);
if ($err) {
$this->writeWarn(
pht('STAGING FAILED'),
pht('Unable to push lfs changes to the staging area.'));
throw new ArcanistUsageException(
pht(
'Failed to push lfs changes to staging area. Correct the issue, '.
'or use --skip-staging to skip this step.'));
}
}
return $refs;
}
/**
* Try to upload lint and unit test results into modern Harbormaster build
* targets.
*
* @return bool True if everything was uploaded to build targets.
*/
private function updateAutotargets($diff_phid, $unit_result) {
$lint_key = 'arcanist.lint';
$unit_key = 'arcanist.unit';
try {
$result = $this->getConduit()->callMethodSynchronous(
'harbormaster.queryautotargets',
array(
'objectPHID' => $diff_phid,
'targetKeys' => array(
$lint_key,
$unit_key,
),
));
$targets = idx($result, 'targetMap', array());
} catch (Exception $ex) {
return false;
}
$futures = array();
$lint_target = idx($targets, $lint_key);
if ($lint_target) {
$lint = nonempty($this->unresolvedLint, array());
foreach ($lint as $key => $message) {
$lint[$key] = $this->getModernLintDictionary($message);
}
// Consider this target to have failed if there are any unresolved
// errors or warnings.
$type = 'pass';
foreach ($lint as $message) {
switch (idx($message, 'severity')) {
case ArcanistLintSeverity::SEVERITY_WARNING:
case ArcanistLintSeverity::SEVERITY_ERROR:
$type = 'fail';
break;
}
}
$futures[] = $this->getConduit()->callMethod(
'harbormaster.sendmessage',
array(
'buildTargetPHID' => $lint_target,
'lint' => array_values($lint),
'type' => $type,
));
}
$unit_target = idx($targets, $unit_key);
if ($unit_target) {
$unit = nonempty($this->testResults, array());
foreach ($unit as $key => $message) {
$unit[$key] = $this->getModernUnitDictionary($message);
}
$type = ArcanistUnitWorkflow::getHarbormasterTypeFromResult($unit_result);
$futures[] = $this->getConduit()->callMethod(
'harbormaster.sendmessage',
array(
'buildTargetPHID' => $unit_target,
'unit' => array_values($unit),
'type' => $type,
));
}
try {
foreach (new FutureIterator($futures) as $future) {
$future->resolve();
}
return true;
} catch (Exception $ex) {
// TODO: Eventually, we should expect these to succeed if we get this
// far, but just log errors for now.
phlog($ex);
return false;
}
}
private function getDependsOnRevisionRef() {
// TODO: Restore this behavior after updating for toolsets. Loading the
// required hardpoints currently depends on a "WorkingCopy" existing.
return null;
$api = $this->getRepositoryAPI();
$base_ref = $api->getBaseCommitRef();
$state_ref = id(new ArcanistWorkingCopyStateRef())
->setCommitRef($base_ref);
$this->loadHardpoints(
$state_ref,
ArcanistWorkingCopyStateRef::HARDPOINT_REVISIONREFS);
$revision_refs = $state_ref->getRevisionRefs();
$viewer_phid = $this->getUserPHID();
foreach ($revision_refs as $key => $revision_ref) {
// Don't automatically depend on closed revisions.
if ($revision_ref->isClosed()) {
unset($revision_refs[$key]);
continue;
}
// Don't automatically depend on revisions authored by other users.
if ($revision_ref->getAuthorPHID() != $viewer_phid) {
unset($revision_refs[$key]);
continue;
}
}
if (!$revision_refs) {
return null;
}
if (count($revision_refs) > 1) {
return null;
}
return head($revision_refs);
}
}
diff --git a/src/workflow/ArcanistUnitWorkflow.php b/src/workflow/ArcanistUnitWorkflow.php
index db01d000..d7b6b53f 100644
--- a/src/workflow/ArcanistUnitWorkflow.php
+++ b/src/workflow/ArcanistUnitWorkflow.php
@@ -1,430 +1,430 @@
<?php
/**
* Runs unit tests which cover your changes.
*/
final class ArcanistUnitWorkflow extends ArcanistWorkflow {
const RESULT_OKAY = 0;
const RESULT_UNSOUND = 1;
const RESULT_FAIL = 2;
const RESULT_SKIP = 3;
private $unresolvedTests;
private $testResults;
private $engine;
public function getWorkflowName() {
return 'unit';
}
public function getCommandSynopses() {
return phutil_console_format(<<<EOTEXT
**unit** [__options__] [__paths__]
**unit** [__options__] --rev [__rev__]
EOTEXT
);
}
public function getCommandHelp() {
return phutil_console_format(<<<EOTEXT
Supports: git, svn, hg
Run unit tests that cover specified paths. If no paths are specified,
unit tests covering all modified files will be run.
EOTEXT
);
}
public function getArguments() {
return array(
'rev' => array(
'param' => 'revision',
'help' => pht(
'Run unit tests covering changes since a specific revision.'),
'supports' => array(
'git',
'hg',
),
'nosupport' => array(
'svn' => pht(
'Arc unit does not currently support %s in SVN.',
'--rev'),
),
),
'engine' => array(
'param' => 'classname',
'help' => pht('Override configured unit engine for this project.'),
),
'coverage' => array(
'help' => pht('Always enable coverage information.'),
'conflicts' => array(
'no-coverage' => null,
),
),
'no-coverage' => array(
'help' => pht('Always disable coverage information.'),
),
'detailed-coverage' => array(
'help' => pht(
'Show a detailed coverage report on the CLI. Implies %s.',
'--coverage'),
),
'json' => array(
'help' => pht('Report results in JSON format.'),
),
'output' => array(
'param' => 'format',
'help' => pht(
"With 'full', show full pretty report (Default). ".
"With 'json', report results in JSON format. ".
"With 'ugly', use uglier (but more efficient) JSON formatting. ".
"With 'none', don't print results."),
'conflicts' => array(
'json' => pht('Only one output format allowed'),
'ugly' => pht('Only one output format allowed'),
),
),
'target' => array(
'param' => 'phid',
'help' => pht(
'(PROTOTYPE) Record a copy of the test results on the specified '.
'Harbormaster build target.'),
),
'everything' => array(
'help' => pht(
'Run every test associated with a tracked file in the working '.
'copy.'),
'conflicts' => array(
'rev' => pht('%s runs all tests.', '--everything'),
),
),
'ugly' => array(
'help' => pht(
'With %s, use uglier (but more efficient) formatting.',
'--json'),
),
'*' => 'paths',
);
}
public function requiresWorkingCopy() {
return true;
}
public function requiresRepositoryAPI() {
return true;
}
public function requiresConduit() {
return $this->shouldUploadResults();
}
public function requiresAuthentication() {
return $this->shouldUploadResults();
}
public function getEngine() {
return $this->engine;
}
public function run() {
$working_copy = $this->getWorkingCopyIdentity();
$paths = $this->getArgument('paths');
$rev = $this->getArgument('rev');
$everything = $this->getArgument('everything');
if ($everything && $paths) {
throw new ArcanistUsageException(
pht(
'You can not specify paths with %s. The %s flag runs every test '.
'associated with a tracked file in the working copy.',
'--everything',
'--everything'));
}
if ($everything) {
$paths = iterator_to_array($this->getRepositoryAPI()->getAllFiles());
} else {
$paths = $this->selectPathsForWorkflow($paths, $rev);
}
$this->engine = $this->newUnitTestEngine($this->getArgument('engine'));
if ($everything) {
$this->engine->setRunAllTests(true);
} else {
$this->engine->setPaths($paths);
}
$renderer = new ArcanistUnitConsoleRenderer();
$this->engine->setRenderer($renderer);
$enable_coverage = null; // Means "default".
if ($this->getArgument('coverage') ||
$this->getArgument('detailed-coverage')) {
$enable_coverage = true;
} else if ($this->getArgument('no-coverage')) {
$enable_coverage = false;
}
$this->engine->setEnableCoverage($enable_coverage);
$results = $this->engine->run();
$this->validateUnitEngineResults($this->engine, $results);
$this->testResults = $results;
$console = PhutilConsole::getConsole();
$output_format = $this->getOutputFormat();
if ($output_format !== 'full') {
$console->disableOut();
}
$unresolved = array();
$coverage = array();
foreach ($results as $result) {
$result_code = $result->getResult();
if ($this->engine->shouldEchoTestResults()) {
$console->writeOut('%s', $renderer->renderUnitResult($result));
}
if ($result_code != ArcanistUnitTestResult::RESULT_PASS) {
$unresolved[] = $result;
}
if ($result->getCoverage()) {
foreach ($result->getCoverage() as $file => $report) {
$coverage[$file][] = $report;
}
}
}
if ($coverage) {
$file_coverage = array_fill_keys(
$paths,
0);
$file_reports = array();
foreach ($coverage as $file => $reports) {
$report = ArcanistUnitTestResult::mergeCoverage($reports);
$cov = substr_count($report, 'C');
$uncov = substr_count($report, 'U');
if ($cov + $uncov) {
$coverage = $cov / ($cov + $uncov);
} else {
$coverage = 0;
}
$file_coverage[$file] = $coverage;
$file_reports[$file] = $report;
}
$console->writeOut("\n__%s__\n", pht('COVERAGE REPORT'));
asort($file_coverage);
foreach ($file_coverage as $file => $coverage) {
$console->writeOut(
" **%s%%** %s\n",
sprintf('% 3d', (int)(100 * $coverage)),
$file);
$full_path = $working_copy->getProjectRoot().'/'.$file;
if ($this->getArgument('detailed-coverage') &&
Filesystem::pathExists($full_path) &&
is_file($full_path) &&
array_key_exists($file, $file_reports)) {
$console->writeOut(
'%s',
$this->renderDetailedCoverageReport(
Filesystem::readFile($full_path),
$file_reports[$file]));
}
}
}
$this->unresolvedTests = $unresolved;
$overall_result = self::RESULT_OKAY;
foreach ($results as $result) {
$result_code = $result->getResult();
if ($result_code == ArcanistUnitTestResult::RESULT_FAIL ||
$result_code == ArcanistUnitTestResult::RESULT_BROKEN) {
$overall_result = self::RESULT_FAIL;
break;
} else if ($result_code == ArcanistUnitTestResult::RESULT_UNSOUND) {
$overall_result = self::RESULT_UNSOUND;
}
}
if ($output_format !== 'full') {
$console->enableOut();
}
$data = array_values(mpull($results, 'toDictionary'));
switch ($output_format) {
case 'ugly':
$console->writeOut('%s', json_encode($data));
break;
case 'json':
$json = new PhutilJSON();
$console->writeOut('%s', $json->encodeFormatted($data));
break;
case 'full':
// already printed
break;
case 'none':
// do nothing
break;
}
$target_phid = $this->getArgument('target');
if ($target_phid) {
$this->uploadTestResults($target_phid, $overall_result, $results);
}
return $overall_result;
}
public function getUnresolvedTests() {
return $this->unresolvedTests;
}
public function getTestResults() {
return $this->testResults;
}
private function renderDetailedCoverageReport($data, $report) {
$data = explode("\n", $data);
$out = '';
$n = 0;
foreach ($data as $line) {
$out .= sprintf('% 5d ', $n + 1);
$line = str_pad($line, 80, ' ');
if (empty($report[$n])) {
$c = 'N';
} else {
$c = $report[$n];
}
switch ($c) {
case 'C':
$out .= phutil_console_format(
'<bg:green> %s </bg>',
$line);
break;
case 'U':
$out .= phutil_console_format(
'<bg:red> %s </bg>',
$line);
break;
case 'X':
$out .= phutil_console_format(
'<bg:magenta> %s </bg>',
$line);
break;
default:
$out .= ' '.$line.' ';
break;
}
$out .= "\n";
$n++;
}
return $out;
}
private function getOutputFormat() {
if ($this->getArgument('ugly')) {
return 'ugly';
}
if ($this->getArgument('json')) {
return 'json';
}
$format = $this->getArgument('output');
$known_formats = array(
'none' => 'none',
'json' => 'json',
'ugly' => 'ugly',
'full' => 'full',
);
return idx($known_formats, $format, 'full');
}
/**
* Raise a tailored error when a unit test engine returns results in an
* invalid format.
*
- * @param ArcanistUnitTestEngine The engine.
- * @param wild Results from the engine.
+ * @param ArcanistUnitTestEngine $engine The engine.
+ * @param wild $results Results from the engine.
*/
private function validateUnitEngineResults(
ArcanistUnitTestEngine $engine,
$results) {
if (!is_array($results)) {
throw new Exception(
pht(
'Unit test engine (of class "%s") returned invalid results when '.
'run (with method "%s"). Expected a list of "%s" objects as results.',
get_class($engine),
'run()',
'ArcanistUnitTestResult'));
}
foreach ($results as $key => $result) {
if (!($result instanceof ArcanistUnitTestResult)) {
throw new Exception(
pht(
'Unit test engine (of class "%s") returned invalid results when '.
'run (with method "%s"). Expected a list of "%s" objects as '.
'results, but value with key "%s" is not valid.',
get_class($engine),
'run()',
'ArcanistUnitTestResult',
$key));
}
}
}
public static function getHarbormasterTypeFromResult($unit_result) {
switch ($unit_result) {
case self::RESULT_OKAY:
case self::RESULT_SKIP:
$type = 'pass';
break;
default:
$type = 'fail';
break;
}
return $type;
}
private function shouldUploadResults() {
return ($this->getArgument('target') !== null);
}
private function uploadTestResults(
$target_phid,
$unit_result,
array $unit) {
// TODO: It would eventually be nice to stream test results up to the
// server as we go, but just get things working for now.
$message_type = self::getHarbormasterTypeFromResult($unit_result);
foreach ($unit as $key => $result) {
$dictionary = $result->toDictionary();
$unit[$key] = $this->getModernUnitDictionary($dictionary);
}
$this->getConduit()->callMethodSynchronous(
'harbormaster.sendmessage',
array(
'buildTargetPHID' => $target_phid,
'unit' => array_values($unit),
'type' => $message_type,
));
}
}
diff --git a/src/workflow/ArcanistWorkflow.php b/src/workflow/ArcanistWorkflow.php
index 3b8242cb..9522812b 100644
--- a/src/workflow/ArcanistWorkflow.php
+++ b/src/workflow/ArcanistWorkflow.php
@@ -1,2475 +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 The URI to open a conduit to when @{method:establishConduit}
- * is called.
+ * @param string $conduit_uri The URI to open a conduit to when
+ * @{method:establishConduit} is called.
* @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
* @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 A credential dictionary, see @{method:authenticateConduit}.
+ * @param dict $credentials A credential dictionary, see
+ * @{method:authenticateConduit}.
* @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
* @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 within the repository.
- * @param string Change selection mode (see ArcanistDiffHunk).
+ * @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 Message to write to stderr.
+ * @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 List of explicitly provided paths.
- * @param string|null Revision name, if provided.
- * @param mask Mask of ArcanistRepositoryAPI flags to exclude.
+ * @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 Scratch file name.
+ * @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 Scratch file name.
+ * @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 Scratch file name to write.
- * @param string Data to write.
+ * @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 Scratch file name to write.
- * @param array Data to write.
+ * @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 Scratch file name to remove.
+ * @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 Scratch file name.
+ * @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 Scratch file name.
+ * @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 Optional explicit engine class name.
+ * @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 Optional explicit engine class name.
+ * @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;
}
}
diff --git a/src/workingcopyidentity/ArcanistWorkingCopyIdentity.php b/src/workingcopyidentity/ArcanistWorkingCopyIdentity.php
index 82a98cbb..d7858681 100644
--- a/src/workingcopyidentity/ArcanistWorkingCopyIdentity.php
+++ b/src/workingcopyidentity/ArcanistWorkingCopyIdentity.php
@@ -1,342 +1,343 @@
<?php
/**
* Interfaces with basic information about the working copy.
*
* @task config
*/
final class ArcanistWorkingCopyIdentity extends Phobject {
private $projectConfig;
private $projectRoot;
private $localConfig = array();
private $localMetaDir;
private $vcsType;
private $vcsRoot;
public static function newDummyWorkingCopy() {
return self::newFromPathWithConfig('/', array());
}
public static function newFromPath($path) {
return self::newFromPathWithConfig($path, null);
}
/**
* Locate all the information we need about a directory which we presume
* to be a working copy. Particularly, we want to discover:
*
* - Is the directory inside a working copy (hg, git, svn)?
* - If so, what is the root of the working copy?
* - Is there a `.arcconfig` file?
*
* This is complicated, mostly because Subversion has special rules. In
* particular:
*
* - Until 1.7, Subversion put a `.svn/` directory inside //every//
* directory in a working copy. After 1.7, it //only// puts one at the
* root.
* - We allow `.arcconfig` to appear anywhere in a Subversion working copy,
* and use the one closest to the directory.
* - Although we may use a `.arcconfig` from a subdirectory, we store
* metadata in the root's `.svn/`, because it's the only one guaranteed
* to exist.
*
* Users also do these kinds of things in the wild:
*
* - Put working copies inside other working copies.
* - Put working copies inside `.git/` directories.
* - Create `.arcconfig` files at `/.arcconfig`, `/home/.arcconfig`, etc.
*
* This method attempts to be robust against all sorts of possible
* misconfiguration.
*
- * @param string Path to load information for, usually the current working
- * directory (unless running unit tests).
- * @param map|null Pass `null` to locate and load a `.arcconfig` file if one
- * exists. Pass a map to use it to set configuration.
+ * @param string $path Path to load information for, usually the current
+ * working directory (unless running unit tests).
+ * @param map|null $config Pass `null` to locate and load a `.arcconfig`
+ * file if one exists. Pass a map to use it to set
+ * configuration.
* @return ArcanistWorkingCopyIdentity Constructed working copy identity.
*/
private static function newFromPathWithConfig($path, $config) {
$project_root = null;
$vcs_root = null;
$vcs_type = null;
// First, find the outermost directory which is a Git, Mercurial or
// Subversion repository, if one exists. We go from the top because this
// makes it easier to identify the root of old SVN working copies (which
// have a ".svn/" directory inside every directory in the working copy) and
// gives us the right result if you have a Git repository inside a
// Subversion repository or something equally ridiculous.
$paths = Filesystem::walkToRoot($path);
$config_paths = array();
$paths = array_reverse($paths);
foreach ($paths as $path_key => $parent_path) {
$try = array(
'git' => $parent_path.'/.git',
'hg' => $parent_path.'/.hg',
'svn' => $parent_path.'/.svn',
);
foreach ($try as $vcs => $try_dir) {
if (!Filesystem::pathExists($try_dir)) {
continue;
}
// NOTE: We're distinguishing between the `$project_root` and the
// `$vcs_root` because they may not be the same in Subversion. Normally,
// they are identical. However, in Subversion, the `$vcs_root` is the
// base directory of the working copy (the directory which has the
// `.svn/` directory, after SVN 1.7), while the `$project_root` might
// be any subdirectory of the `$vcs_root`: it's the the directory
// closest to the current directory which contains a `.arcconfig`.
$project_root = $parent_path;
$vcs_root = $parent_path;
$vcs_type = $vcs;
if ($vcs == 'svn') {
// For Subversion, we'll look for a ".arcconfig" file here or in
// any subdirectory, starting at the deepest subdirectory.
$config_paths = array_slice($paths, $path_key);
$config_paths = array_reverse($config_paths);
} else {
// For Git and Mercurial, we'll only look for ".arcconfig" right here.
$config_paths = array($parent_path);
}
break;
}
}
$console = PhutilConsole::getConsole();
$looked_in = array();
foreach ($config_paths as $config_path) {
$config_file = $config_path.'/.arcconfig';
$looked_in[] = $config_file;
if (Filesystem::pathExists($config_file)) {
// We always need to examine the filesystem to look for `.arcconfig`
// so we can set the project root correctly. We might or might not
// actually read the file: if the caller passed in configuration data,
// we'll ignore the actual file contents.
$project_root = $config_path;
if ($config === null) {
$console->writeLog(
"%s\n",
pht(
'Working Copy: Reading %s from "%s".',
'.arcconfig',
$config_file));
$config_data = Filesystem::readFile($config_file);
$config = self::parseRawConfigFile($config_data, $config_file);
}
break;
}
}
if ($config === null) {
if ($looked_in) {
$console->writeLog(
"%s\n",
pht(
'Working Copy: Unable to find %s in any of these locations: %s.',
'.arcconfig',
implode(', ', $looked_in)));
} else {
$console->writeLog(
"%s\n",
pht(
'Working Copy: No candidate locations for %s from '.
'this working directory.',
'.arcconfig'));
}
$config = array();
}
if ($project_root === null) {
// We aren't in a working directory at all. This is fine if we're
// running a command like "arc help". If we're running something that
// requires a working directory, an exception will be raised a little
// later on.
$console->writeLog(
"%s\n",
pht('Working Copy: Path "%s" is not in any working copy.', $path));
return new ArcanistWorkingCopyIdentity($path, $config);
}
$console->writeLog(
"%s\n",
pht(
'Working Copy: Path "%s" is part of `%s` working copy "%s".',
$path,
$vcs_type,
$vcs_root));
$console->writeLog(
"%s\n",
pht(
'Working Copy: Project root is at "%s".',
$project_root));
$identity = new ArcanistWorkingCopyIdentity($project_root, $config);
$identity->localMetaDir = $vcs_root.'/.'.$vcs_type;
$identity->localConfig = $identity->readLocalArcConfig();
$identity->vcsType = $vcs_type;
$identity->vcsRoot = $vcs_root;
return $identity;
}
public static function newFromRootAndConfigFile(
$root,
$config_raw,
$from_where) {
if ($config_raw === null) {
$config = array();
} else {
$config = self::parseRawConfigFile($config_raw, $from_where);
}
return self::newFromPathWithConfig($root, $config);
}
private static function parseRawConfigFile($raw_config, $from_where) {
try {
return phutil_json_decode($raw_config);
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht("Unable to parse '%s' file '%s'.", '.arcconfig', $from_where),
$ex);
}
}
private function __construct($root, array $config) {
$this->projectRoot = $root;
$this->projectConfig = $config;
}
public function getProjectRoot() {
return $this->projectRoot;
}
public function getProjectPath($to_file) {
return $this->projectRoot.'/'.$to_file;
}
public function getVCSType() {
return $this->vcsType;
}
public function getVCSRoot() {
return $this->vcsRoot;
}
/* -( Config )------------------------------------------------------------- */
public function readProjectConfig() {
return $this->projectConfig;
}
/**
* Read a configuration directive from project configuration. This reads ONLY
* permanent project configuration (i.e., ".arcconfig"), not other
* configuration sources. See @{method:getConfigFromAnySource} to read from
* user configuration.
*
- * @param key Key to read.
- * @param wild Default value if key is not found.
+ * @param key $key Key to read.
+ * @param wild $default (Optional) Default value if key is not found.
* @return wild Value, or default value if not found.
*
* @task config
*/
public function getProjectConfig($key, $default = null) {
$settings = new ArcanistSettings();
$pval = idx($this->projectConfig, $key);
// Test for older names in the per-project config only, since
// they've only been used there.
if ($pval === null) {
$legacy = $settings->getLegacyName($key);
if ($legacy) {
$pval = $this->getProjectConfig($legacy);
}
}
if ($pval === null) {
$pval = $default;
} else {
$pval = $settings->willReadValue($key, $pval);
}
return $pval;
}
/**
* Read a configuration directive from local configuration. This
* reads ONLY the per-working copy configuration,
* i.e. .(git|hg|svn)/arc/config, and not other configuration
* sources. See @{method:getConfigFromAnySource} to read from any
* config source or @{method:getProjectConfig} to read permanent
* project-level config.
*
* @task config
*/
public function getLocalConfig($key, $default = null) {
return idx($this->localConfig, $key, $default);
}
public function readLocalArcConfig() {
if ($this->localMetaDir !== null && strlen($this->localMetaDir)) {
$local_path = Filesystem::resolvePath('arc/config', $this->localMetaDir);
$console = PhutilConsole::getConsole();
if (Filesystem::pathExists($local_path)) {
$console->writeLog(
"%s\n",
pht(
'Config: Reading local configuration file "%s"...',
$local_path));
try {
$json = Filesystem::readFile($local_path);
return phutil_json_decode($json);
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht("Failed to parse '%s' as JSON.", $local_path),
$ex);
}
} else {
$console->writeLog(
"%s\n",
pht(
'Config: Did not find local configuration at "%s".',
$local_path));
}
}
return array();
}
public function writeLocalArcConfig(array $config) {
$json_encoder = new PhutilJSON();
$json = $json_encoder->encodeFormatted($config);
$dir = $this->localMetaDir;
if (!strlen($dir)) {
throw new Exception(pht('No working copy to write config into!'));
}
$local_dir = $dir.DIRECTORY_SEPARATOR.'arc';
if (!Filesystem::pathExists($local_dir)) {
Filesystem::createDirectory($local_dir, 0755);
}
$config_file = $local_dir.DIRECTORY_SEPARATOR.'config';
Filesystem::writeFile($config_file, $json);
}
}
diff --git a/src/xsprintf/csprintf.php b/src/xsprintf/csprintf.php
index ead744a8..0818d34c 100644
--- a/src/xsprintf/csprintf.php
+++ b/src/xsprintf/csprintf.php
@@ -1,140 +1,140 @@
<?php
/**
* Format a shell command string. This function behaves like `sprintf`, except
* that all the normal conversions (like "%s") will be properly escaped, and
* additional conversions are supported:
*
* %Ls
* List of strings that will be escaped. They will be space separated.
*
* %LR
* List of "readable" strings. They will be space separated.
*
* %P
* Password (or other sensitive parameter) to escape. Pass a
* @{class:PhutilOpaqueEnvelope}.
*
* %C (Raw Command)
* Passes the argument through without escaping. Dangerous!
*
* %R
* A more "readable" version of "%s". This will try to print the command
* without any escaping if it contains only characters which are safe
* in any context. The intent is to produce prettier human-readable
* commands.
*
* Generally, you should invoke shell commands via @{function:execx} rather
* than by calling @{function:csprintf} directly.
*
- * @param string sprintf()-style format string.
+ * @param string $pattern sprintf()-style format string.
* @param ... Zero or more arguments.
* @return PhutilCommandString Formatted string, escaped appropriately for
* shell contexts.
*/
function csprintf($pattern /* , ... */) {
$args = func_get_args();
return new PhutilCommandString($args);
}
/**
* Version of @{function:csprintf} that takes a vector of arguments.
*
- * @param string sprintf()-style format string.
- * @param list List of zero or more arguments to csprintf().
+ * @param string $pattern sprintf()-style format string.
+ * @param list $argv List of zero or more arguments to csprintf().
* @return PhutilCommandString Formatted string, escaped appropriately for
* shell contexts.
*/
function vcsprintf($pattern, array $argv) {
array_unshift($argv, $pattern);
return call_user_func_array('csprintf', $argv);
}
/**
* @{function:xsprintf} callback for @{function:csprintf}.
*/
function xsprintf_command($userdata, &$pattern, &$pos, &$value, &$length) {
$type = $pattern[$pos];
$next = (strlen($pattern) > $pos + 1) ? $pattern[$pos + 1] : null;
$is_unmasked = !empty($userdata['unmasked']);
if (empty($userdata['mode'])) {
$mode = PhutilCommandString::MODE_DEFAULT;
} else {
$mode = $userdata['mode'];
}
if ($value instanceof PhutilCommandString) {
if ($is_unmasked) {
$value = $value->getUnmaskedString();
} else {
$value = $value->getMaskedString();
}
}
switch ($type) {
case 'L':
// Remove the L.
$pattern = substr_replace($pattern, '', $pos, 1);
$length = strlen($pattern);
$type = 's';
// Check that the value is a non-empty array.
if (!is_array($value)) {
throw new InvalidArgumentException(
pht('Expected an array for %%L%s conversion.', $next));
}
switch ($next) {
case 's':
$values = array();
foreach ($value as $val) {
$values[] = csprintf('%s', $val);
}
$value = implode(' ', $values);
break;
case 'R':
$values = array();
foreach ($value as $val) {
$values[] = csprintf('%R', $val);
}
$value = implode(' ', $values);
break;
default:
throw new XsprintfUnknownConversionException("%L{$next}");
}
break;
case 'R':
if (!preg_match('(^[a-zA-Z0-9:/@._+-]+$)', $value)) {
$value = PhutilCommandString::escapeArgument($value, $mode);
}
$type = 's';
break;
case 's':
$value = PhutilCommandString::escapeArgument($value, $mode);
$type = 's';
break;
case 'P':
if (!($value instanceof PhutilOpaqueEnvelope)) {
throw new InvalidArgumentException(
pht('Expected %s for %%P conversion.', 'PhutilOpaqueEnvelope'));
}
if ($is_unmasked) {
$value = $value->openEnvelope();
} else {
$value = '********';
}
$value = PhutilCommandString::escapeArgument($value, $mode);
$type = 's';
break;
case 'C':
$type = 's';
break;
}
$pattern[$pos] = $type;
}
diff --git a/src/xsprintf/pregsprintf.php b/src/xsprintf/pregsprintf.php
index 05ac86f4..f227b6e4 100644
--- a/src/xsprintf/pregsprintf.php
+++ b/src/xsprintf/pregsprintf.php
@@ -1,45 +1,45 @@
<?php
/**
* Format a regular expression. Supports the following conversions:
*
* %s String
* Escapes a string using `preg_quote`.
*
* %R Raw
* Inserts a raw regular expression.
*
- * @param string sprintf()-style format string.
- * @param string Flags to use with the regular expression.
- * @param ... Zero or more arguments.
+ * @param string $pattern sprintf()-style format string.
+ * @param string (optional) Flags to use with the regular expression.
+ * @param ... (optional) Zero or more arguments.
* @return string Formatted string.
*/
function pregsprintf($pattern /* , ... */) {
$args = func_get_args();
$flags = head(array_splice($args, 1, 1));
$delim = chr(7);
$userdata = array('delimiter' => $delim);
$pattern = xsprintf('xsprintf_regex', $userdata, $args);
return $delim.$pattern.$delim.$flags;
}
/**
* @{function:xsprintf} callback for regular expressions.
*/
function xsprintf_regex($userdata, &$pattern, &$pos, &$value, &$length) {
$delim = idx($userdata, 'delimiter');
$type = $pattern[$pos];
switch ($type) {
case 's':
$value = preg_quote($value, $delim);
break;
case 'R':
$type = 's';
break;
}
$pattern[$pos] = $type;
}
diff --git a/src/xsprintf/xsprintf.php b/src/xsprintf/xsprintf.php
index cdc0c9ed..ec2a6a08 100644
--- a/src/xsprintf/xsprintf.php
+++ b/src/xsprintf/xsprintf.php
@@ -1,139 +1,139 @@
<?php
/**
* Parse a `sprintf()`-style format string in an extensible way.
*
* This method allows you to build a function with `sprintf()` semantics but
* custom conversions for different datatypes. Three examples are
* @{function:jsprintf} (which builds JavaScript strings),
* @{function:qsprintf} (which builds MySQL strings), and
* @{function:csprintf} (which builds command line strings).
*
* To build a new `xsprintf`-family function, provide a callback which conforms
* to the specification described in @{function:xsprintf_callback_example}. The
* callback will be invoked each time a conversion (like "%Z") is encountered
* in the string. For instance, if you call `xsprintf()` like this...
*
* $result = xsprintf(
* 'xsprintf_callback_example',
* $userdata = null,
* array(
* 'The %M is made of %C.',
* 'moon',
* 'cheese',
* ));
*
* ...the callback will be invoked twice, at string positions 5 ("M") and 19
* ("C"), with values "moon" and "cheese" respectively.
*
- * @param string The name of a callback to pass conversions to.
- * @param wild Optional userdata to pass to the callback. For
+ * @param string $callback The name of a callback to pass conversions to.
+ * @param wild $userdata Optional userdata to pass to the callback. For
* @{function:qsprintf}, this is the database connection.
- * @param list List of arguments, with the `sprintf()` pattern in
+ * @param list $argv List of arguments, with the `sprintf()` pattern in
* position 0.
* @return string Formatted string.
*/
function xsprintf($callback, $userdata, array $argv) {
$argc = count($argv);
$arg = 0;
$pos = 0;
$pattern = $argv[0];
$len = strlen($pattern);
$conv = false; // Are we inside a conversion?
for ($pos = 0; $pos < $len; $pos++) {
$c = $pattern[$pos];
if ($conv) {
// We could make a greater effort to support formatting modifiers,
// but they really have no place in semantic string formatting.
if (strpos("'-0123456789.\$+", $c) !== false) {
throw new InvalidArgumentException(
pht(
'%s does not support the "%s" modifier.',
'xsprintf()',
"%{$c}"));
}
if ($c !== '%') {
$conv = false;
$arg++;
if ($arg >= $argc) {
throw new BadFunctionCallException(
pht(
'Too few arguments to %s.',
'xsprintf()'));
}
if ($callback !== null) {
// See T13588 and D21500. This function uses "$callback()", instead
// of "call_user_func()", to simplify reference behavior: some of
// these arguments must be passed by reference.
// Prior to PHP7, this syntax will not work if "$callback" is a
// string referencing a static method, like "C::m".
// This syntax does work if "$callback" is an array referencing
// a static method, like "array('C', 'm')", in all versions of PHP
// since PHP 5.4.
$callback($userdata, $pattern, $pos, $argv[$arg], $len);
}
}
}
if ($c === '%') {
// If we have "%%", this encodes a literal percentage symbol, so we are
// no longer inside a conversion.
$conv = !$conv;
}
}
if ($arg !== ($argc - 1)) {
throw new BadFunctionCallException(
pht(
'Too many arguments to %s.',
'xsprintf()'));
}
$argv[0] = $pattern;
return call_user_func_array('sprintf', $argv);
}
/**
* Example @{function:xsprintf} callback. When you call `xsprintf`, you must
* pass a callback like this one. `xsprintf` will invoke the callback when it
* encounters a conversion (like "%Z") in the pattern string.
*
* Generally, this callback should examine `$pattern[$pos]` (which will contain
* the conversion character, like 'Z'), escape `$value` appropriately, and then
* replace `$pattern[$pos]` with an 's' so `sprintf` prints the escaped value
* as a string. However, more sophisticated behaviors are possible --
* particularly, consuming multiple characters to allow for conversions like
* "%Ld". In this case, the callback needs to `substr_replace` the entire
* conversion with 's' and then update `$length`.
*
* For example implementations, see @{function:xsprintf_command},
* @{function:xsprintf_javascript} and @{function:xsprintf_query}.
*
- * @param wild Arbitrary, optional userdata. This is whatever userdata
- * was passed to @{function:xsprintf}.
- * @param string The pattern string being parsed.
- * @param int The current character position in the string.
- * @param wild The value to convert.
- * @param int The string length.
+ * @param wild $userdata Arbitrary, optional userdata. This is whatever
+ * userdata was passed to @{function:xsprintf}.
+ * @param string &$pattern The pattern string being parsed.
+ * @param int &$pos The current character position in the string.
+ * @param wild &$value The value to convert.
+ * @param int &$length The string length.
*/
function xsprintf_callback_example(
$userdata,
&$pattern,
&$pos,
&$value,
&$length) {
throw new RuntimeException(
pht(
'This function exists only to document the call signature '.
'for %s callbacks.',
'xsprintf()'));
}

File Metadata

Mime Type
text/x-diff
Expires
Sun, May 11, 06:25 (18 h, 7 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1142118
Default Alt Text
(1 MB)

Event Timeline