diff --git a/src/console/__tests__/PhutilConsoleWrapTestCase.php b/src/console/__tests__/PhutilConsoleWrapTestCase.php index 8d1af5c..f36dfef 100644 --- a/src/console/__tests__/PhutilConsoleWrapTestCase.php +++ b/src/console/__tests__/PhutilConsoleWrapTestCase.php @@ -1,63 +1,66 @@ assertEqual( Filesystem::readFile($dir.$file.'.expect'), phutil_console_wrap(Filesystem::readFile($dir.$file)), $file); } } } public function testConsoleWrap() { $this->assertEqual( phutil_console_format( "** ERROR ** abc abc abc abc abc abc abc abc abc abc ". "abc abc abc abc abc abc abc\nabc abc abc abc abc abc abc abc abc ". "abc abc!"), phutil_console_wrap( phutil_console_format( "** ERROR ** abc abc abc abc abc abc abc abc abc abc ". "abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc ". "abc abc!")), 'ANSI escape sequences should not contribute toward wrap width.'); } public function testWrapIndent() { $turtles = <<assertEqual( $turtles, phutil_console_wrap( rtrim(str_repeat('turtle ', 20)), $indent = 20)); } } diff --git a/src/error/aggregate/PhutilAggregateException.php b/src/error/aggregate/PhutilAggregateException.php index 05e50a8..eec0305 100644 --- a/src/error/aggregate/PhutilAggregateException.php +++ b/src/error/aggregate/PhutilAggregateException.php @@ -1,61 +1,62 @@ doSomething(); * $success = true; * break; * } catch (Exception $ex) { * $exceptions[] = $ex; * } * } * * if (!$success) { * throw new PhutilAggregateException("All engines failed:", $exceptions); * } * * @concrete-extensible + * @group error */ class PhutilAggregateException extends Exception { private $exceptions = array(); public function __construct($message, array $other_exceptions) { $this->exceptions = $other_exceptions; $full_message = array(); $full_message[] = $message; foreach ($other_exceptions as $exception) { $ex_message = $exception->getMessage(); $ex_message = ' - '.str_replace("\n", "\n ", $ex_message); $full_message[] = $ex_message; } parent::__construct(implode("\n", $full_message)); } } diff --git a/src/filesystem/deferredlog/PhutilDeferredLog.php b/src/filesystem/deferredlog/PhutilDeferredLog.php index d52b871..3ee35f6 100644 --- a/src/filesystem/deferredlog/PhutilDeferredLog.php +++ b/src/filesystem/deferredlog/PhutilDeferredLog.php @@ -1,185 +1,186 @@ 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 @{function: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 internal Internals + * @group filesystem */ final class PhutilDeferredLog { private $file; private $format; private $data; private $didWrite; /* -( 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 The file the entry should be written to. * @param string 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. * @return this * @task log */ public function setData(array $map) { $this->data = $map + $this->data; return $this; } /** * When the log object is destroyed, it writes if it hasn't written yet. * @task log */ 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 log */ public function write() { if ($this->didWrite) { return $this; } $line = $this->format(); $ok = @file_put_contents( $this->file, $line, FILE_APPEND | LOCK_EX); if ($ok === false) { throw new Exception( "Unable to write to logfile '{$this->file}'!"); } $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() { $byte = "\1"; $line = $this->format; $line = addcslashes($line, $byte); $line = str_replace("%", $byte, $line); $find = array(); $repl = array(); foreach ($this->data as $key => $value) { $find[] = $byte.str_replace('%', $byte, $key); $repl[] = addcslashes($value, "\0..\37\\\177..\377"); } $line = str_replace($find, $repl, $line); $line = preg_replace("/{$byte}./", '-', $line); $line = rtrim($line)."\n"; return $line; } } diff --git a/src/filesystem/deferredlog/__tests__/PhutilDeferredLogTestCase.php b/src/filesystem/deferredlog/__tests__/PhutilDeferredLogTestCase.php index 6c025a5..2c1e4bf 100644 --- a/src/filesystem/deferredlog/__tests__/PhutilDeferredLogTestCase.php +++ b/src/filesystem/deferredlog/__tests__/PhutilDeferredLogTestCase.php @@ -1,136 +1,141 @@ checkLog( "derp\n", "derp", array()); $this->checkLog( "[20 Aug 1984] alincoln\n", "[%T] %u", array( 'T' => '20 Aug 1984', 'u' => 'alincoln', )); $this->checkLog( "%%%%%\n", "%%%%%%%%%%", array( '%' => '%', )); $this->checkLog( "\\000\\001\\002\n", "%a%b%c", array( 'a' => chr(0), 'b' => chr(1), 'c' => chr(2), )); $this->checkLog( "Download: 100%\n", "Download: %C", array( 'C' => '100%', )); $this->checkLog( "- bee -\n", "%a %b %c", array( 'b' => 'bee', )); $this->checkLog( "\\\\\n", "%b", array( 'b' => '\\', )); $this->checkLog( "a\t\\t\n", "%a\t%b", array( 'a' => 'a', 'b' => "\t", )); $this->checkLog( "\\001ab\n", "\1a%a", array( 'a' => 'b', )); } public function testLogWriteFailure() { $caught = null; try { - $log = new PhutilDeferredLog('/derp/derp/derp/derp/derp', 'derp'); if (phutil_is_hiphop_runtime()) { // In HipHop exceptions thrown in destructors are not normally // catchable, so call __destruct() explicitly. + $log = new PhutilDeferredLog('/derp/derp/derp/derp/derp', 'derp'); $log->__destruct(); + } else { + new PhutilDeferredLog('/derp/derp/derp/derp/derp', 'derp'); } } catch (Exception $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); } public function testManyWriters() { $root = phutil_get_library_root('phutil').'/../'; $bin = $root.'scripts/test/deferred_log.php'; $n_writers = 3; $n_lines = 8; $tmp = new TempFile(); $futures = array(); for ($ii = 0; $ii < $n_writers; $ii++) { $futures[] = new ExecFuture("%s %d %s", $bin, $n_lines, (string)$tmp); } Futures($futures)->resolveAll(); $this->assertEqual( str_repeat("abcdefghijklmnopqrstuvwxyz\n", $n_writers * $n_lines), Filesystem::readFile($tmp)); } private function checkLog($expect, $format, $data) { $tmp = new TempFile(); $log = new PhutilDeferredLog($tmp, $format); $log->setData($data); unset($log); $this->assertEqual($expect, Filesystem::readFile($tmp), $format); } } diff --git a/src/filesystem/linesofalarge/execfuture/__tests__/LinesOfALargeExecFutureTestCase.php b/src/filesystem/linesofalarge/execfuture/__tests__/LinesOfALargeExecFutureTestCase.php index e307ee1..98c20a7 100644 --- a/src/filesystem/linesofalarge/execfuture/__tests__/LinesOfALargeExecFutureTestCase.php +++ b/src/filesystem/linesofalarge/execfuture/__tests__/LinesOfALargeExecFutureTestCase.php @@ -1,76 +1,79 @@ writeAndRead( "cat\ndog\nbird\n", array( "cat", "dog", "bird", )); } public function testExecLargeFile() { $line = "The quick brown fox jumps over the lazy dog."; $n = 100; $this->writeAndRead( str_repeat($line."\n", $n), array_fill(0, $n, $line)); } public function testExecLongLine() { $line = str_repeat('x', 64 * 1024); $this->writeAndRead($line, array($line)); } public function testExecException() { $caught = null; try { $future = new ExecFuture('does-not-exist.exe.sh'); foreach (new LinesOfALargeExecFuture($future) as $line) { // ignore } } catch (Exception $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof CommandException); } private function writeAndRead($write, $read) { $future = new ExecFuture('cat'); $future->write($write); $lines = array(); foreach (new LinesOfALargeExecFuture($future) as $line) { $lines[] = $line; } $this->assertEqual( $read, $lines, "Write: ".phutil_utf8_shorten($write, 32)); } } diff --git a/src/filesystem/linesofalarge/file/__tests__/LinesOfALargeFileTestCase.php b/src/filesystem/linesofalarge/file/__tests__/LinesOfALargeFileTestCase.php index 8b0d923..af0f553 100644 --- a/src/filesystem/linesofalarge/file/__tests__/LinesOfALargeFileTestCase.php +++ b/src/filesystem/linesofalarge/file/__tests__/LinesOfALargeFileTestCase.php @@ -1,112 +1,115 @@ writeAndRead( "abcd", array( "abcd", )); } public function testTerminalDelimiterPresent() { $this->writeAndRead( "bat\ncat\ndog\n", array( "bat", "cat", "dog", )); } public function testTerminalDelimiterAbsent() { $this->writeAndRead( "bat\ncat\ndog", array( "bat", "cat", "dog", )); } public function testChangeDelimiter() { $this->writeAndRead( "bat\1cat\1dog\1", array( "bat", "cat", "dog", ), "\1"); } public function testEmptyLines() { $this->writeAndRead( "\n\nbat\n", array( '', '', 'bat', )); } public function testLargeFile() { $line = "The quick brown fox jumps over the lazy dog."; $n = 100; $this->writeAndRead( str_repeat($line."\n", $n), array_fill(0, $n, $line)); } public function testLongLine() { $line = str_repeat('x', 64 * 1024); $this->writeAndRead($line, array($line)); } public function testReadFailure() { $caught = null; try { $f = new LinesOfALargeFile('/does/not/exist.void'); $f->rewind(); } catch (FilesystemException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof $ex); } private function writeAndRead($write, $read, $delimiter = "\n") { $tmp = new TempFile(); Filesystem::writeFile($tmp, $write); $lines = array(); $iterator = id(new LinesOfALargeFile($tmp))->setDelimiter($delimiter); foreach ($iterator as $line) { $lines[] = $line; } $this->assertEqual( $read, $lines, "Write: ".phutil_utf8_shorten($write, 32)); } } diff --git a/src/future/exec/__tests__/ExecFutureTestCase.php b/src/future/exec/__tests__/ExecFutureTestCase.php index 96b8260..875ff1c 100644 --- a/src/future/exec/__tests__/ExecFutureTestCase.php +++ b/src/future/exec/__tests__/ExecFutureTestCase.php @@ -1,113 +1,116 @@ write('')->resolvex(); $this->assertEqual('', $stdout); } public function testKeepPipe() { // NOTE: This is mosty testing the semantics of $keep_pipe in write(). list($stdout) = id(new ExecFuture('cat')) ->write('', true) ->start() ->write('x', true) ->write('y', true) ->write('z', false) ->resolvex(); $this->assertEqual('xyz', $stdout); } public function testLargeBuffer() { // NOTE: This is mostly a coverage test to hit branches where we're still // flushing a buffer. $data = str_repeat('x', 1024 * 1024 * 4); list($stdout) = id(new ExecFuture('cat'))->write($data)->resolvex(); $this->assertEqual($data, $stdout); } public function testBufferLimit() { $data = str_repeat('x', 1024 * 1024); list($stdout) = id(new ExecFuture('cat')) ->setStdoutSizeLimit(1024) ->write($data) ->resolvex(); $this->assertEqual(substr($data, 0, 1024), $stdout); } public function testResolveTimeoutTestShouldRunLessThan1Sec() { // NOTE: This tests interactions between the resolve() timeout and the // ExecFuture timeout, which are similar but not identical. $future = id(new ExecFuture('sleep 32000'))->start(); $future->setTimeout(32000); // We expect this to return in 0.01s. $result = $future->resolve(0.01); $this->assertEqual($result, null); // We expect this to now force the time out / kill immediately. If we don't // do this, we'll hang when exiting until our subprocess exits (32000 // seconds!) $future->setTimeout(0.01); $future->resolve(); } public function testTimeoutTestShouldRunLessThan1Sec() { // NOTE: This is partly testing that we choose appropriate select wait // times; this test should run for significantly less than 1 second. $future = new ExecFuture('sleep 32000'); list($err) = $future->setTimeout(0.01)->resolve(); $this->assertEqual(true, $err > 0); $this->assertEqual(true, $future->getWasKilledByTimeout()); } public function testMultipleTimeoutsTestShouldRunLessThan1Sec() { $futures = array(); for ($ii = 0; $ii < 4; $ii++) { $futures[] = id(new ExecFuture('sleep 32000'))->setTimeout(0.01); } foreach (Futures($futures) as $future) { list ($err) = $future->resolve(); $this->assertEqual(true, $err > 0); $this->assertEqual(true, $future->getWasKilledByTimeout()); } } } diff --git a/src/markup/syntax/highlighter/exception/PhutilSyntaxHighlighterException.php b/src/markup/syntax/highlighter/exception/PhutilSyntaxHighlighterException.php index 2fdfd9a..69d0068 100644 --- a/src/markup/syntax/highlighter/exception/PhutilSyntaxHighlighterException.php +++ b/src/markup/syntax/highlighter/exception/PhutilSyntaxHighlighterException.php @@ -1,20 +1,23 @@ setTagline('make an new dog') * $args->setSynopsis(<<parse( * 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 + * + * @group console */ final class PhutilArgumentParser { private $bin; private $argv; private $specs = array(); private $results = array(); private $parsed; private $tagline; private $synopsis; private $workflows; private $showHelp; /* -( 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. * @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 * retreive 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 * @{class:PhutilArgumentSpecification}. * @return this * @task parse */ public function parsePartial(array $specs) { $specs = PhutilArgumentSpecification::newSpecsFromList($specs); $this->mergeSpecs($specs); $specs_by_name = mpull($specs, null, 'getName'); $specs_by_short = mpull($specs, null, 'getShortAlias'); unset($specs_by_short[null]); $argv = $this->argv; $len = count($argv); for ($ii = 0; $ii < $len; $ii++) { $arg = $argv[$ii]; $map = null; if ($arg == '--') { // This indicates "end of flags". 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; } else if (!strncmp('-', $arg, 1) && strlen($arg) > 1) { $pre = '-'; $arg = substr($arg, 1); $map = $specs_by_short; } if ($map) { $val = null; $parts = explode('=', $arg, 2); if (count($parts) == 2) { list($arg, $val) = $parts; } if (isset($map[$arg])) { $spec = $map[$arg]; unset($argv[$ii]); $param_name = $spec->getParamName(); if ($val !== null) { if ($param_name === null) { throw new PhutilArgumentUsageException( "Argument '{$pre}{$arg}' does not take a parameter."); } } else { if ($param_name !== null) { if ($ii + 1 < $len) { $val = $argv[$ii + 1]; unset($argv[$ii + 1]); $ii++; } else { throw new PhutilArgumentUsageException( "Argument '{$pre}{$arg}' requires a parameter."); } } else { $val = true; } } if (!$spec->getRepeatable()) { if (array_key_exists($spec->getName(), $this->results)) { throw new PhutilArgumentUsageException( "Argument '{$pre}{$arg}' was provided twice."); } } $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( "Argument '{$pre}{$arg}' conflicts with argument ". "'--{$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 * @{class:PhutilArgumentSpecification}. * @return this * @task parse */ public function parseFull(array $specs) { $this->parsePartial($specs); if (count($this->argv)) { $arg = head($this->argv); throw new PhutilArgumentUsageException( "Unrecognized argument '{$arg}'."); } 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 * @{class:PhutilArgumentSpecification}. * @return this * @task parse */ public function parse(array $specs) { try { return $this->parseFull($specs); } catch (PhutilArgumentUsageException $ex) { $this->printUsageException($ex); exit(77); } } /** * 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 * @{class:PhutilArgumentSpecification}. * @return this * @task parse */ public function parseWorkflows(array $workflows) { try { return $this->parseWorkflowsFull($workflows); } catch (PhutilArgumentUsageException $ex) { $this->printUsageException($ex); exit(77); } } /** * 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. * @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'); foreach ($workflows as $workflow) { $name = $workflow->getName(); if ($name === null) { throw new PhutilArgumentSpecificationException( "Workflow has no name!"); } if (isset($this->workflows[$name])) { throw new PhutilArgumentSpecificationException( "Two workflows with name '{$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( "No workflow selected."); } } $flow = array_shift($argv); $flow = strtolower($flow); if (empty($this->workflows[$flow])) { throw new PhutilArgumentUsageException( "Invalid workflow '{$flow}'."); } $workflow = $this->workflows[$flow]; if ($this->showHelp) { $this->printHelpAndExit(); } $this->argv = array_values($argv); $this->parse($workflow->getArguments()); if ($workflow->isExecutable()) { $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 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' => 'Trace command execution and show service calls.', ), array( 'name' => 'no-ansi', 'help' => 'Disable ANSI terminal codes, printing plain text with '. 'no color or style.', ), array( 'name' => 'xprofile', 'param' => 'profile', 'help' => 'Profile script execution and write results to a file.', ), array( 'name' => 'help', 'short' => 'h', 'help' => 'Show this help.', ), )); } catch (PhutilArgumentUsageException $ex) { $this->printUsageException($ex); exit(77); } if ($this->getArg('trace')) { PhutilServiceProfiler::installEchoListener(); } if ($this->getArg('no-ansi')) { PhutilConsoleFormatter::disableANSI(true); } if (function_exists('posix_isatty') && !posix_isatty(STDOUT)) { PhutilConsoleFormatter::disableANSI(true); } if ($this->getArg('help')) { $this->showHelp = true; } $xprofile = $this->getArg('xprofile'); if ($xprofile) { if (!function_exists('xhprof_enable')) { throw new Exception("To use '--xprofile', you must install XHProf."); } xhprof_enable(0); register_shutdown_function(array($this, 'shutdownProfiler')); } return $this; } /* -( Reading Arguments )-------------------------------------------------- */ public function getArg($name) { if (empty($this->specs[$name])) { throw new PhutilArgumentSpecificationException( "No specification exists for argument '{$name}'!"); } if (idx($this->results, $name) !== null) { return $this->results[$name]; } return $this->specs[$name]->getDefault(); } public function getUnconsumedArgumentVector() { return $this->argv; } /* -( Command Help )------------------------------------------------------- */ 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(77); } public function renderHelp() { $out = array(); if ($this->bin) { $out[] = $this->format('**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('**SYNOPSIS**'); $out[] = $this->indent(6, $this->synopsis); $out[] = null; } if ($this->workflows) { $has_help = false; $out[] = $this->format('**WORKFLOWS**'); $out[] = null; $flows = $this->workflows; ksort($flows); foreach ($flows as $workflow) { if ($workflow->getName() == 'help') { $has_help = true; } $out[] = $this->renderWorkflowHelp( $workflow->getName(), $show_flags = false); } if ($has_help) { $out[] = $this->indent( 6, "Use **help** __command__ for a detailed command reference.\n"); } } $specs = $this->renderArgumentSpecs($this->specs); if ($specs) { $out[] = $this->format('**OPTION REFERENCE**'); $out[] = null; $out[] = $specs; } $out[] = null; return implode("\n", $out); } public function renderWorkflowHelp( $workflow_name, $show_flags = false) { $out = array(); $workflow = idx($this->workflows, strtolower($workflow_name)); if (!$workflow) { $out[] = $this->indent( 6, "There is no **{$workflow_name}** workflow."); } else { $out[] = $this->indent(6, $workflow->getExamples()); $out[] = $this->indent(6, $workflow->getSynopsis()); if ($show_flags) { $specs = $this->renderArgumentSpecs($workflow->getArguments()); if ($specs) { $out[] = null; $out[] = $specs; } } } $out[] = null; return implode("\n", $out); } public function printUsageException(PhutilArgumentUsageException $ex) { file_put_contents( 'php://stderr', $this->format('**Usage Exception:** '.$ex->getMessage()."\n")); } /* -( Internals )---------------------------------------------------------- */ private function filterWildcardArgv(array $argv) { foreach ($argv as $key => $value) { if ($value == '--') { unset($argv[$key]); break; } else if (!strncmp($value, '-', 1) && strlen($value) > 1) { throw new PhutilArgumentUsageException( "Argument '{$value}' is unrecognized. Use '--' to indicate the ". "end of flags."); } } 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( "Two argument specifications have the same name ('{$name}')."); } $short = $spec->getShortAlias(); if ($short) { if (isset($short_map[$short])) { throw new PhutilArgumentSpecificationException( "Two argument specifications have the same short alias ". "('{$short}')."); } $short_map[$short] = $spec; } if ($spec->getWildcard()) { if ($wildcard) { throw new PhutilArgumentSpecificationException( "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( "Argument '{$name}' conflicts with unspecified argument ". "'{$conflict}'."); } if ($conflict == $name) { throw new PhutilArgumentSpecificationException( "Argument '{$name}' conflicts with itself!"); } } } } private function renderArgumentSpecs(array $specs) { foreach ($specs as $key => $spec) { if ($spec->getWildcard()) { unset($specs[$key]); } } $out = array(); $specs = msort($specs, 'getName'); foreach ($specs as $spec) { $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 = serialize($data); Filesystem::writeFile($this->getArg('xprofile'), $data); } } diff --git a/src/parser/argument/parser/__tests__/PhutilArgumentParserTestCase.php b/src/parser/argument/parser/__tests__/PhutilArgumentParserTestCase.php index d2d65ea..44940c8 100644 --- a/src/parser/argument/parser/__tests__/PhutilArgumentParserTestCase.php +++ b/src/parser/argument/parser/__tests__/PhutilArgumentParserTestCase.php @@ -1,422 +1,425 @@ 'flag', )); $args = new PhutilArgumentParser(array('bin')); $args->parseFull($specs); $this->assertEqual(false, $args->getArg('flag')); $args = new PhutilArgumentParser(array('bin', '--flag')); $args->parseFull($specs); $this->assertEqual(true, $args->getArg('flag')); } public function testWildcards() { $specs = array( array( 'name' => 'flag', ), array( 'name' => 'files', 'wildcard' => true, ), ); $args = new PhutilArgumentParser(array('bin', '--flag', 'a', 'b')); $args->parseFull($specs); $this->assertEqual(true, $args->getArg('flag')); $this->assertEqual( array('a', 'b'), $args->getArg('files')); $caught = null; try { $args = new PhutilArgumentParser(array('bin', '--derp', 'a', 'b')); $args->parseFull($specs); } catch (PhutilArgumentUsageException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); $args = new PhutilArgumentParser(array('bin', '--', '--derp', 'a', 'b')); $args->parseFull($specs); $this->assertEqual( array('--derp', 'a', 'b'), $args->getArg('files')); } public function testPartialParse() { $specs = array( array( 'name' => 'flag', ), ); $args = new PhutilArgumentParser(array('bin', 'a', '--flag', '--', 'b')); $args->parsePartial($specs); $this->assertEqual( array('a', '--', 'b'), $args->getUnconsumedArgumentVector()); } public function testBadArg() { $args = new PhutilArgumentParser(array('bin')); $args->parseFull(array()); $caught = null; try { $args->getArg('flag'); } catch (PhutilArgumentSpecificationException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); } public function testDuplicateNames() { $args = new PhutilArgumentParser(array('bin')); $caught = null; try { $args->parseFull( array( array( 'name' => 'x', ), array( 'name' => 'x', ))); } catch (PhutilArgumentSpecificationException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); } public function testDuplicateNamesWithParsePartial() { $args = new PhutilArgumentParser(array('bin')); $caught = null; try { $args->parsePartial( array( array( 'name' => 'x', ))); $args->parsePartial( array( array( 'name' => 'x', ))); } catch (PhutilArgumentSpecificationException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); } public function testDuplicateShortAliases() { $args = new PhutilArgumentParser(array('bin')); $caught = null; try { $args->parseFull( array( array( 'name' => 'x', 'short' => 'x', ), array( 'name' => 'y', 'short' => 'x', ))); } catch (PhutilArgumentSpecificationException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); } public function testDuplicateWildcards() { $args = new PhutilArgumentParser(array('bin')); $caught = null; try { $args->parseFull( array( array( 'name' => 'x', 'wildcard' => true, ), array( 'name' => 'y', 'wildcard' => true, ))); } catch (PhutilArgumentSpecificationException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); } public function testDuplicatePartialWildcards() { $args = new PhutilArgumentParser(array('bin')); $caught = null; try { $args->parsePartial( array( array( 'name' => 'x', 'wildcard' => true, ), )); $args->parsePartial( array( array( 'name' => 'y', 'wildcard' => true, ), )); } catch (PhutilArgumentSpecificationException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); } public function testConflictSpecificationWithUnrecognizedArg() { $args = new PhutilArgumentParser(array('bin')); $caught = null; try { $args->parseFull( array( array( 'name' => 'x', 'conflicts' => array( 'y' => true, ), ), )); } catch (PhutilArgumentSpecificationException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); } public function testConflictSpecificationWithSelf() { $args = new PhutilArgumentParser(array('bin')); $caught = null; try { $args->parseFull( array( array( 'name' => 'x', 'conflicts' => array( 'x' => true, ), ), )); } catch (PhutilArgumentSpecificationException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); } public function testUnrecognizedFlag() { $args = new PhutilArgumentParser(array('bin', '--flag')); $caught = null; try { $args->parseFull(array()); } catch (PhutilArgumentUsageException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); } public function testDuplicateFlag() { $args = new PhutilArgumentParser(array('bin', '--flag', '--flag')); $caught = null; try { $args->parseFull( array( array( 'name' => 'flag', ), )); } catch (PhutilArgumentUsageException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); } public function testMissingParameterValue() { $args = new PhutilArgumentParser(array('bin', '--with')); $caught = null; try { $args->parseFull( array( array( 'name' => 'with', 'param' => 'stuff', ), )); } catch (PhutilArgumentUsageException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); } public function testExtraParameterValue() { $args = new PhutilArgumentParser(array('bin', '--true=apple')); $caught = null; try { $args->parseFull( array( array( 'name' => 'true', ), )); } catch (PhutilArgumentUsageException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); } public function testConflictParameterValue() { $args = new PhutilArgumentParser(array('bin', '--true', '--false')); $caught = null; try { $args->parseFull( array( array( 'name' => 'true', 'conflicts' => array( 'false' => true, ), ), array( 'name' => 'false', 'conflicts' => array( 'true' => true, ), ), )); } catch (PhutilArgumentUsageException $ex) { $caught = $ex; } $this->assertEqual(true, $caught instanceof Exception); } public function testParameterValues() { $specs = array( array( 'name' => 'a', 'param' => 'value', ), array( 'name' => 'b', 'param' => 'value', ), array( 'name' => 'cee', 'short' => 'c', 'param' => 'value', ), array( 'name' => 'dee', 'short' => 'd', 'param' => 'value', ), ); $args = new PhutilArgumentParser( array( 'bin', '--a', 'a', '--b=b', '-c', 'c', '-d=d', )); $args->parseFull($specs); $this->assertEqual('a', $args->getArg('a')); $this->assertEqual('b', $args->getArg('b')); $this->assertEqual('c', $args->getArg('cee')); $this->assertEqual('d', $args->getArg('dee')); } public function testStdinValidParameter() { $specs = array( array( 'name' => 'file', 'param' => 'file', ), ); $args = new PhutilArgumentParser( array( 'bin', '-', '--file', '-', )); $args->parsePartial($specs); $this->assertEqual('-', $args->getArg('file')); } public function testRepeatableFlag() { $specs = array( array( 'name' => 'verbose', 'short' => 'v', 'repeat' => true, ), ); $args = new PhutilArgumentParser(array('bin', '-v', '-v', '-v')); $args->parseFull($specs); $this->assertEqual(3, $args->getArg('verbose')); } public function testRepeatableParam() { $specs = array( array( 'name' => 'eat', 'param' => 'fruit', 'repeat' => true, ), ); $args = new PhutilArgumentParser(array( 'bin', '--eat', 'apple', '--eat', 'pear', '--eat=orange', )); $args->parseFull($specs); $this->assertEqual( array('apple', 'pear', 'orange'), $args->getArg('eat')); } } diff --git a/src/parser/argument/spec/PhutilArgumentSpecification.php b/src/parser/argument/spec/PhutilArgumentSpecification.php index ab51ed1..6313341 100644 --- a/src/parser/argument/spec/PhutilArgumentSpecification.php +++ b/src/parser/argument/spec/PhutilArgumentSpecification.php @@ -1,260 +1,263 @@ '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. * @return PhutilArgumentSpecification Constructed argument specification. */ public static function newQuickSpec(array $spec) { $recognized_keys = array( 'name', 'help', 'short', 'param', 'default', 'conflicts', 'wildcard', 'repeat', ); $unrecognized = array_diff_key( $spec, array_fill_keys($recognized_keys, true)); foreach ($unrecognized as $key => $ignored) { throw new PhutilArgumentSpecificationException( "Unrecognized key '{$key}' in argument specification. Recognized keys ". "are: ".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; } } $obj->validate(); return $obj; } public static function newSpecsFromList(array $specs) { foreach ($specs as $key => $spec) { if (is_array($spec)) { $specs[$key] = PhutilArgumentSpecification::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( "Argument names may only contain a-z, 0-9 and -, and must be ". "at least one character long. '{$name}' is invalid."); } } 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( "Argument short aliases must be exactly one character long. ". "'{$alias}' is invalid."); } if (!preg_match('/^[a-zA-Z0-9]$/', $alias)) { throw new PhutilArgumentSpecificationException( "Argument short aliases may only be in a-z, A-Z and 0-9. ". "'{$alias}' is invalid."); } } 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 validate() { if ($this->name === null) { throw new PhutilArgumentSpecificationException( "Argument specification MUST have a 'name'."); } if ($this->getWildcard()) { if ($this->getParamName()) { throw new PhutilArgumentSpecificationException( "Wildcard arguments may not specify a parameter."); } if ($this->getRepeatable()) { throw new PhutilArgumentSpecificationException( "Wildcard arguments may not be repeatable."); } } if ($this->default !== null) { if ($this->getRepeatable()) { throw new PhutilArgumentSpecificationException( "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( "Flag arguments may not have a default (always false)."); } } } } diff --git a/src/parser/argument/spec/__tests__/PhutilArgumentSpecificationTestCase.php b/src/parser/argument/spec/__tests__/PhutilArgumentSpecificationTestCase.php index 79c7962..b8e240c 100644 --- a/src/parser/argument/spec/__tests__/PhutilArgumentSpecificationTestCase.php +++ b/src/parser/argument/spec/__tests__/PhutilArgumentSpecificationTestCase.php @@ -1,158 +1,161 @@ true, 'xx' => true, '!' => false, 'XX' => false, '1=' => false, '--' => false, 'no-stuff' => true, '-stuff' => false, ); foreach ($names as $name => $valid) { $caught = null; try { PhutilArgumentSpecification::newQuickSpec( array( 'name' => $name, )); } catch (PhutilArgumentSpecificationException $ex) { $caught = $ex; } $this->assertEqual( !$valid, $caught instanceof Exception, "Arg name '{$name}'."); } } public function testAliases() { $aliases = array( 'a' => true, '1' => true, 'no' => false, '-' => false, '_' => false, ' ' => false, '' => false, ); foreach ($aliases as $alias => $valid) { $caught = null; try { PhutilArgumentSpecification::newQuickSpec( array( 'name' => 'example', 'short' => $alias, )); } catch (PhutilArgumentSpecificationException $ex) { $caught = $ex; } $this->assertEqual( !$valid, $caught instanceof Exception, "Arg alias '{$alias}'."); } } public function testSpecs() { $good_specs = array( array( 'name' => 'verbose', ), array( 'name' => 'verbose', 'short' => 'v', 'help' => 'Derp.', 'param' => 'level', 'default' => 'y', 'conflicts' => array( 'quiet' => true, ), 'wildcard' => false, ), array( 'name' => 'files', 'wildcard' => true, ), ); $bad_specs = array( array( ), array( 'alias' => 'v', ), array( 'name' => 'derp', 'fruit' => 'apple', ), array( 'name' => 'x', 'default' => 'y', ), array( 'name' => 'x', 'param' => 'y', 'default' => 'z', 'repeat' => true, ), array( 'name' => 'x', 'wildcard' => true, 'repeat' => true, ), array( 'name' => 'x', 'param' => 'y', 'wildcard' => true, ), ); $cases = array( array(true, $good_specs), array(false, $bad_specs), ); foreach ($cases as $case) { list($expect, $specs) = $case; foreach ($specs as $spec) { $caught = null; try { PhutilArgumentSpecification::newQuickSpec($spec); } catch (PhutilArgumentSpecificationException $ex) { $caught = $ex; } $this->assertEqual( !$expect, $caught instanceof Exception, "Spec validity for: ".print_r($spec, true)); } } } } diff --git a/src/parser/argument/workflow/base/PhutilArgumentWorkflow.php b/src/parser/argument/workflow/base/PhutilArgumentWorkflow.php index 1e06e7c..44a89a5 100644 --- a/src/parser/argument/workflow/base/PhutilArgumentWorkflow.php +++ b/src/parser/argument/workflow/base/PhutilArgumentWorkflow.php @@ -1,156 +1,157 @@ setTagline('simple calculator example'); * $args->setSynopsis(<<setName('add') * ->setExamples('**add** __n__ ...') * ->setSynopsis('Compute the sum of a list of numbers.') * ->setArguments( * array( * array( * 'name' => 'numbers', * 'wildcard' => true, * ), * )); * * $mul_workflow = id(new PhutilArgumentWorkflow()) * ->setName('mul') * ->setExamples('**mul** __n__ ...') * ->setSynopsis('Compute the product of a list of numbers.') * ->setArguments( * array( * array( * 'name' => 'numbers', * 'wildcard' => true, * ), * )); * * $flow = $args->parseWorkflows( * array( * $add_workflow, * $mul_workflow, * new PhutilHelpArgumentWorkflow(), * )); * * $nums = $args->getArg('numbers'); * if (empty($nums)) { * echo "You must provide one or more numbers!\n"; * exit(1); * } * * foreach ($nums as $num) { * if (!is_numeric($num)) { * echo "Number '{$num}' is not numeric!\n"; * exit(1); * } * } * * switch ($flow->getName()) { * case 'add': * echo array_sum($nums)."\n"; * break; * case 'mul': * echo array_product($nums)."\n"; * break; * } * * You can also subclass this class and return `true` from * @{method:isExecutable}. In this case, the parser will automatically select * your workflow when the user invokes it. * * @stable * @concrete-extensible + * @group console */ class PhutilArgumentWorkflow { private $name; private $synopsis; private $specs = array(); private $examples; final public function __construct() { $this->didConstruct(); } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } final public function setExamples($examples) { $this->examples = $examples; return $this; } final public function getExamples() { if (!$this->examples) { return "**".$this->name."**"; } return $this->examples; } final public function setSynopsis($synopsis) { $this->synopsis = $synopsis; return $this; } final public function getSynopsis() { return $this->synopsis; } final public function setArguments(array $specs) { $specs = PhutilArgumentSpecification::newSpecsFromList($specs); $this->specs = $specs; return $this; } final public function getArguments() { return $this->specs; } protected function didConstruct() { return null; } public function isExecutable() { return false; } public function execute(PhutilArgumentParser $args) { throw new Exception("This workflow isn't executable!"); } } diff --git a/src/parser/argument/workflow/help/PhutilHelpArgumentWorkflow.php b/src/parser/argument/workflow/help/PhutilHelpArgumentWorkflow.php index bc95b62..0047fad 100644 --- a/src/parser/argument/workflow/help/PhutilHelpArgumentWorkflow.php +++ b/src/parser/argument/workflow/help/PhutilHelpArgumentWorkflow.php @@ -1,60 +1,63 @@ setName('help'); $this->setExamples(<<setSynopsis(<<setArguments( array( array( 'name' => 'help-with-what', 'wildcard' => true, ))); } public function isExecutable() { return true; } public function execute(PhutilArgumentParser $args) { $with = $args->getArg('help-with-what'); if (!$with) { $args->printHelpAndExit(); } else { foreach ($with as $thing) { echo phutil_console_format( "**%s WORKFLOW**\n\n", strtoupper($thing)); echo $args->renderWorkflowHelp($thing, $show_flags = true); echo "\n"; } exit(77); } } } diff --git a/src/parser/languageguesser/__tests__/PhutilLanguageGuesserTestCase.php b/src/parser/languageguesser/__tests__/PhutilLanguageGuesserTestCase.php index 3444202..c0583ef 100644 --- a/src/parser/languageguesser/__tests__/PhutilLanguageGuesserTestCase.php +++ b/src/parser/languageguesser/__tests__/PhutilLanguageGuesserTestCase.php @@ -1,39 +1,42 @@ assertEqual( $expect, PhutilLanguageGuesser::guessLanguage($source), "Guessed language for '{$test}'."); } } }