diff --git a/.arcconfig b/.arcconfig new file mode 100644 index 0000000..edbc47a --- /dev/null +++ b/.arcconfig @@ -0,0 +1,6 @@ +{ + "project_id" : "libphutil", + "conduit_uri" : "http://tools.epriestley-conduit.dev1557.facebook.com/api/", + "lint_engine" : "PhutilLintEngine", + "copyright_holder" : "Facebook, Inc." +} diff --git a/.divinerconfig b/.divinerconfig new file mode 100644 index 0000000..90e3d13 --- /dev/null +++ b/.divinerconfig @@ -0,0 +1,4 @@ +{ + "name" : "libphutil" +} + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..225c74c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +._* +docs/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..60686e6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2011 Facebook, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README b/README new file mode 100644 index 0000000..1623e5b --- /dev/null +++ b/README @@ -0,0 +1,33 @@ +PROJECT STATUS: CAVEAT EMPTOR + +This is an unstable preview release. I'm open sourcing some of Facebook's +internal tools, but they'll be unstable for at least a couple months. +-epriestley + + +WHAT IS LIBPHUTIL? + +libphutil is a collection of utility classes and functions for PHP. + +Futures + Futures (also known as "promises") are objects which act as placeholders for + some future result of computation. They let you express parallel and + asynchronous execution with a natural syntax. There are two provided concrete + Future implementations: + ExecFuture: execute system commands with a Future-based API + HTTPFuture: execute simple HTTP requests with a Future-based API + execx(): exception-based alternative to exec() with more capabilities + +Filesystem + The builtin PHP filesystem functions return error codes and emit warnings. + It is tedious to check these consistently. The Filesystem class provides a + simple API for common filesystem operations that throws exceptions on failure. + +xsprintf + This module allows you to build sprintf()-style functions that have arbitrary + conversions. This is particularly useful for escaping data correctly. Three + concrete implementations are provided: + csprintf: safely escape data for system commands + jsprintf: safely escape data for Javascript + qsprintf: safely escape data for MySQL + diff --git a/resources/git/commit-template.txt b/resources/git/commit-template.txt new file mode 100644 index 0000000..81633ed --- /dev/null +++ b/resources/git/commit-template.txt @@ -0,0 +1,10 @@ +<> + +Summary: + +Test Plan: + +Reviewers: + +CC: + diff --git a/src/__phutil_library_init__.php b/src/__phutil_library_init__.php new file mode 100644 index 0000000..74c2a27 --- /dev/null +++ b/src/__phutil_library_init__.php @@ -0,0 +1,78 @@ + + array( + 'CommandException' => 'future/exec', + 'ConduitClient' => 'conduit/client', + 'ConduitClientException' => 'conduit/client', + 'ConduitFuture' => 'conduit/client', + 'ExecFuture' => 'future/exec', + 'FileFinder' => 'filesystem/filefinder', + 'FileList' => 'filesystem/filelist', + 'Filesystem' => 'filesystem', + 'FilesystemException' => 'filesystem', + 'Future' => 'future', + 'FutureIterator' => 'future', + 'HTTPFuture' => 'future/http', + 'PhutilConsoleFormatter' => 'console', + 'PhutilInteractiveEditor' => 'console/editor', + 'PhutilLibraryMapRegistry' => 'autoload', + 'PhutilMarkupEngine' => 'markup/engine', + 'PhutilRemarkupBlockStorage' => 'markup/engine/remarkup/blockstorage', + 'PhutilRemarkupEngine' => 'markup/engine/remarkup', + 'PhutilRemarkupEngineBlockRule' => 'markup/engine/remarkup/blockrule/base', + 'PhutilRemarkupEngineRemarkupCodeBlockRule' => 'markup/engine/remarkup/blockrule/remarkupcode', + 'PhutilRemarkupEngineRemarkupCounterExampleBlockRule' => 'markup/engine/remarkup/blockrule/remarkupcounterexample', + 'PhutilRemarkupEngineRemarkupDefaultBlockRule' => 'markup/engine/remarkup/blockrule/remarkupdefault', + 'PhutilRemarkupEngineRemarkupHeaderBlockRule' => 'markup/engine/remarkup/blockrule/remarkupheader', + 'PhutilRemarkupEngineRemarkupInlineBlockRule' => 'markup/engine/remarkup/blockrule/remarkupinline', + 'PhutilRemarkupEngineRemarkupListBlockRule' => 'markup/engine/remarkup/blockrule/remarkuplist', + 'PhutilRemarkupRule' => 'markup/engine/remarkup/markuprule/base', + 'PhutilRemarkupRuleBold' => 'markup/engine/remarkup/markuprule/bold', + 'PhutilRemarkupRuleEscapeHTML' => 'markup/engine/remarkup/markuprule/escapehtml', + 'PhutilRemarkupRuleEscapeRemarkup' => 'markup/engine/remarkup/markuprule/escaperemarkup', + 'PhutilRemarkupRuleHyperlink' => 'markup/engine/remarkup/markuprule/hyperlink', + 'PhutilRemarkupRuleItalic' => 'markup/engine/remarkup/markuprule/italics', + 'PhutilRemarkupRuleLinebreaks' => 'markup/engine/remarkup/markuprule/linebreaks', + 'PhutilRemarkupRuleMonospace' => 'markup/engine/remarkup/markuprule/monospace', + 'QsprintfQueryParameterException' => 'xsprintf/qsprintf', + 'TempFile' => 'filesystem/tempfile', + ), + 'function' => + array( + 'Futures' => 'future', + '_qsprintf_check_scalar_type' => 'xsprintf/qsprintf', + '_qsprintf_check_type' => 'xsprintf/qsprintf', + 'array_select_keys' => 'utils', + 'coalesce' => 'utils', + 'csprintf' => 'xsprintf/csprintf', + 'exec_manual' => 'future/exec', + 'execx' => 'future/exec', + 'id' => 'utils', + 'idx' => 'utils', + 'ipull' => 'utils', + 'jsprintf' => 'xsprintf/jsprintf', + 'mgroup' => 'utils', + 'mpull' => 'utils', + 'msort' => 'utils', + 'mysql_escape_array_of_strings_for_in_clause' => 'xsprintf/qsprintf', + 'mysql_escape_column_name' => 'xsprintf/qsprintf', + 'mysql_escape_multiline_comment' => 'xsprintf/qsprintf', + 'newv' => 'utils', + 'nonempty' => 'utils', + 'phutil_autoload_class' => 'autoload', + 'phutil_autoload_function' => 'autoload', + 'phutil_console_confirm' => 'console', + 'phutil_console_format' => 'console', + 'phutil_console_prompt' => 'console', + 'phutil_console_wrap' => 'console', + 'phutil_escape_html' => 'markup', + 'phutil_find_class_descendants' => 'autoload', + 'phutil_find_classes_declared_in_module' => 'autoload', + 'phutil_get_library_name_for_root' => 'moduleutils', + 'phutil_get_library_root' => 'moduleutils', + 'phutil_get_library_root_for_path' => 'moduleutils', + 'phutil_register_library_map' => 'autoload', + 'phutil_render_tag' => 'markup', + 'qsprintf' => 'xsprintf/qsprintf', + 'vcsprintf' => 'xsprintf/csprintf', + 'vjsprintf' => 'xsprintf/jsprintf', + 'vqsprintf' => 'xsprintf/qsprintf', + 'xsprintf' => 'xsprintf', + 'xsprintf_command' => 'xsprintf/csprintf', + 'xsprintf_javascript' => 'xsprintf/jsprintf', + 'xsprintf_query' => 'xsprintf/qsprintf', + ), + 'requires_class' => + array( + 'ConduitFuture' => 'HTTPFuture', + 'ExecFuture' => 'Future', + 'HTTPFuture' => 'Future', + 'PhutilRemarkupEngine' => 'PhutilMarkupEngine', + 'PhutilRemarkupEngineRemarkupCodeBlockRule' => 'PhutilRemarkupEngineBlockRule', + 'PhutilRemarkupEngineRemarkupCounterExampleBlockRule' => 'PhutilRemarkupEngineBlockRule', + 'PhutilRemarkupEngineRemarkupDefaultBlockRule' => 'PhutilRemarkupEngineBlockRule', + 'PhutilRemarkupEngineRemarkupHeaderBlockRule' => 'PhutilRemarkupEngineBlockRule', + 'PhutilRemarkupEngineRemarkupInlineBlockRule' => 'PhutilRemarkupEngineBlockRule', + 'PhutilRemarkupEngineRemarkupListBlockRule' => 'PhutilRemarkupEngineBlockRule', + 'PhutilRemarkupRuleBold' => 'PhutilRemarkupRule', + 'PhutilRemarkupRuleEscapeHTML' => 'PhutilRemarkupRule', + 'PhutilRemarkupRuleEscapeRemarkup' => 'PhutilRemarkupRule', + 'PhutilRemarkupRuleHyperlink' => 'PhutilRemarkupRule', + 'PhutilRemarkupRuleItalic' => 'PhutilRemarkupRule', + 'PhutilRemarkupRuleLinebreaks' => 'PhutilRemarkupRule', + 'PhutilRemarkupRuleMonospace' => 'PhutilRemarkupRule', + ), + 'requires_interface' => + array( + ), +)); diff --git a/src/autoload/PhutilLibraryMapRegistry.php b/src/autoload/PhutilLibraryMapRegistry.php new file mode 100644 index 0000000..94645be --- /dev/null +++ b/src/autoload/PhutilLibraryMapRegistry.php @@ -0,0 +1,163 @@ + $map) { + foreach ($map['requires_class'] as $child => $parent) { + if ($parent == $class) { + $children[] = $child; + } + } + foreach ($map['requires_interface'] as $child => $parent) { + if ($parent == $class) { + $children[] = $child; + } + } + } + + $descendants = array( + $children, + ); + foreach ($children as $child) { + $descendants[] = self::findDescendantsOfClass($child); + } + return call_user_func_array('array_merge', $descendants); + } + + public static function findClassesDeclaredInModule($library, $module) { + if (empty(self::$map[$library])) { + $map = self::loadLibraryMap($library); + } else { + $map = self::$map[$library]; + } + + $results = array(); + foreach ($map['class'] as $class => $declared) { + if ($declared == $module) { + $results[] = $class; + } + } + + return $results; + } + + public static function register(array $map) { + self::$map[self::$library] = $map; + } + + public static function loadLibraryMap($library) { + if (!empty(self::$map[$library])) { + return self::$map[$library]; + } + self::$library = $library; + $root = __phutil_library_registry('find', $library); + $okay = @include_once $root.'/__phutil_library_map__.php'; + if (!$okay) { + throw new Exception("Unable to load library map for '{$library}'."); + } + return self::$map[$library]; + } + + public static function findClass($library, $class, $search = true) { + return self::findSymbolOfType('class', $library, $class, $search); + } + + public static function findFunction($library, $function, $search = true) { + return self::findSymbolOfType('function', $library, $function, $search); + } + + public static function findSymbolOfType($type, $library, $symbol, $search) { + if ($library) { + $map = self::loadLibraryMap($library); + if (isset($map[$library][$type][$symbol])) { + return self::getSpec($type, $library, $symbol); + } + } + + if (!$search) { + return false; + } + + foreach (self::$map as $library => $map) { + if (isset($map[$type][$symbol])) { + return self::getSpec($type, $library, $symbol); + } + } + + foreach (array_keys(__phutil_library_registry('list')) as $library) { + if (empty(self::$map[$library])) { + $map = self::loadLibraryMap($library); + if (isset($map[$type][$symbol])) { + return self::getSpec($type, $library, $symbol); + } + } + } + + return false; + } + + private static function getSpec($type, $library, $symbol) { + if ($type == 'function') { + return self::getFunctionSpec($library, $symbol); + } else { + return self::getClassSpec($library, $symbol); + } + } + + private static function getFunctionSpec($library, $function) { + $map = self::$map[$library]; + return array( + 'library' => $library, + 'module' => $map['function'][$function], + ); + } + + private static function getClassSpec($library, $class) { + $map = self::$map[$library]; + $module = $map['class'][$class]; + $parent = null; + $interface = array(); + + if (!empty($map['requires_class'][$class])) { + $parent = $map['requires_class'][$class]; + } + + if (!empty($map['requires_interface'][$class])) { + $interface = $map['requires_interface'][$class]; + } + + return array( + 'library' => $library, + 'module' => $module, + 'requires_class' => $parent, + 'requires_interface' => $interface, + ); + } + +} diff --git a/src/autoload/__init__.php b/src/autoload/__init__.php new file mode 100644 index 0000000..f0070c7 --- /dev/null +++ b/src/autoload/__init__.php @@ -0,0 +1,20 @@ +connectionID = $connection_id; + return $this; + } + + public function getConnectionID() { + return $this->connectionID; + } + + public function __construct($uri) { + $this->host = parse_url($uri, PHP_URL_HOST); + $this->path = parse_url($uri, PHP_URL_PATH); + + if (!$this->host) { + throw new Exception("Conduit URI '{$uri}' must include a valid host."); + } + + $this->path = trim($this->path, '/').'/'; + } + + public function callMethodSynchronous($method, array $params) { + return $this->callMethod($method, $params)->resolve(); + } + + public function callMethod($method, array $params) { + + $meta = array(); + if ($this->getConnectionID()) { + $meta['connectionID'] = $this->getConnectionID(); + } + + if ($meta) { + $params['__conduit__'] = $meta; + } + + $start_time = microtime(true); + $future = new ConduitFuture( + 'http://'.$this->host.'/'.$this->path.$method, + array( + 'params' => json_encode($params), + 'output' => 'json', + )); + $future->setMethod('POST'); + $future->isReady(); + + if ($this->getTraceMode()) { + $future_name = $method; + $future->setTraceMode(true); + $future->setStartTime($start_time); + $future->setTraceName($future_name); + echo "[Conduit] >>> Send {$future_name}()...\n"; + } + + return $future; + } + + public function setTraceMode($mode) { + $this->traceMode = $mode; + return $this; + } + + protected function getTraceMode() { + if (!empty($this->traceMode)) { + return true; + } + return false; + } +} diff --git a/src/conduit/client/ConduitClientException.php b/src/conduit/client/ConduitClientException.php new file mode 100644 index 0000000..987295f --- /dev/null +++ b/src/conduit/client/ConduitClientException.php @@ -0,0 +1,32 @@ +errorCode = $code; + } + + public function getErrorCode() { + return $this->errorCode; + } + +} diff --git a/src/conduit/client/ConduitFuture.php b/src/conduit/client/ConduitFuture.php new file mode 100644 index 0000000..75cc4ca --- /dev/null +++ b/src/conduit/client/ConduitFuture.php @@ -0,0 +1,75 @@ +traceMode = $trace_mode; + return $this; + } + + public function setTraceName($trace_name) { + $this->traceName = $trace_name; + return $this; + } + + public function setStartTime($time) { + $this->startTime = $time; + return $this; + } + + protected function getResult() { + $result = parent::getResult(); + + if (empty($this->endTime)) { + $this->endTime = microtime(true); + $time = (int)(1000 * ($this->endTime - $this->startTime)); + $time = number_format($time).' ms'; + if ($this->traceMode) { + echo "[Conduit] <<< Completed {$this->traceName} in {$time}.\n"; + } + } + + if ($result[0] !== 200) { + throw new Exception( + "Host returned an HTTP error response #{$result[0]} in response ". + "to a Conduit method call."); + } + + $data = json_decode($result[1], true); + if (!is_array($data)) { + throw new Exception( + "Host returned HTTP/200, but invalid JSON data in response to ". + "a Conduit method call:\n{$result[1]}"); + } + + if ($data['error_code']) { + throw new ConduitClientException( + $data['error_code'], + $data['error_info']); + } + + return $data['result']; + } + +} diff --git a/src/conduit/client/__init__.php b/src/conduit/client/__init__.php new file mode 100644 index 0000000..b90bf32 --- /dev/null +++ b/src/conduit/client/__init__.php @@ -0,0 +1,23 @@ + 0, + 'red' => 1, + 'green' => 2, + 'yellow' => 3, + 'blue' => 4, + 'magenta' => 5, + 'cyan' => 6, + 'white' => 7, + 'default' => 9, + ); + + + public static function formatString($format /* ... */) { + $esc = chr(27); + $bold = $esc.'[1m'.'\\1'.$esc.'[m'; + $underline = $esc.'[4m'.'\\1'.$esc.'[m'; + $invert = $esc.'[7m'.'\\1'.$esc.'[m'; + + $colors = implode('|', array_keys(self::$colorCodes)); + + $format = preg_replace('/\*\*(.*)\*\*/sU', $bold, $format); + $format = preg_replace('/__(.*)__/sU', $underline, $format); + $format = preg_replace('/##(.*)##/sU', $invert, $format); + $format = preg_replace_callback( + '@<(fg|bg):('.$colors.')>(.*)@sU', + array('PhutilConsoleFormatter', 'replaceColorCode'), + $format); + + $args = func_get_args(); + $args[0] = $format; + + return call_user_func_array('sprintf', $args); + } + + public static function replaceColorCode($matches) { + $codes = self::$colorCodes; + $offset = 30 + $codes[$matches[2]]; + $default = 39; + if ($matches[1] == 'bg') { + $offset += 10; + $default += 10; + } + + return chr(27).'['.$offset.'m'.$matches[3].chr(27).'['.$default.'m'; + } + +} diff --git a/src/console/__init__.php b/src/console/__init__.php new file mode 100644 index 0000000..a1f4577 --- /dev/null +++ b/src/console/__init__.php @@ -0,0 +1,20 @@ +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 { + + private $name = ''; + private $content = ''; + private $offset = 0; + private $fallback = 'nano'; + + +/* -( Creating a New Editor )---------------------------------------------- */ + + + /** + * Constructs an interactive editor, using the text of a document. + * + * @param string Document text. + * @return $this + * + * @task create + */ + public function __construct($content) { + $this->setContent($content); + } + + +/* -( Editing Interactively )----------------------------------------------- */ + + + /** + * Launch an editor and edit the content. The edited content will be + * returned. + * + * @return string Edited content. + * @throws Exception The editor exited abnormally or something untoward + * occurred. + * + * @task edit + */ + public function editInteractively() { + $name = $this->getName(); + $content = $this->getContent(); + + $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(); + + $arg_editor = $editor; + $arg_offset = escapeshellarg($offset); + $arg_path = escapeshellarg($path); + + $err = 0; + passthru("{$arg_editor} +{$arg_offset} {$arg_path}", $err); + + if ($err) { + Filesystem::remove($tmp); + throw new Exception("Editor exited with an error code (#{$err})."); + } + + try { + $result = Filesystem::readFile($path); + Filesystem::remove($tmp); + } catch (Exception $ex) { + Filesystem::remove($tmp); + throw $ex; + } + + $this->setContent($result); + + return $this->getContent(); + } + + +/* -( Configuring Options )------------------------------------------------- */ + + + /** + * Set the line offset where the cursor should be positioned when the editor + * opens. By default, the cursor will be positioned at the start of the + * content. + * + * @param int Line number where the cursor should be positioned. + * @return $this + * + * @task config + */ + public function setLineOffset($offset) { + $this->offset = (int)$offset; + return $this; + } + + + /** + * Get the current line offset. See setLineOffset(). + * + * @return int Current line offset. + * + * @task config + */ + public function getLineOffset() { + return $this->offset; + } + + + /** + * Set the document name. Depending on the editor, this may be exposed to + * the user and can give them a sense of what they're editing. + * + * @param string Document name. + * @return $this + * + * @task config + */ + public function setName($name) { + $name = preg_replace('/[^A-Z0-9._-]+/i', '', $name); + $this->name = $name; + return $this; + } + + + /** + * Get the current document name. See setName() for details. + * + * @return string Current document name. + * + * @task config + */ + public function getName() { + if (!strlen($this->name)) { + return 'untitled'; + } + return $this->name; + } + + + /** + * Set the text content to be edited. + * + * @param string New content. + * @return $this + * + * @task config + */ + public function setContent($content) { + $this->content = $content; + return $this; + } + + + /** + * Retrieve the current content. + * + * @return string + * + * @task config + */ + public function getContent() { + return $this->content; + } + + /** + * Set the fallback editor program to be used if the env variable $EDITOR + * is not available. + * + * @param string Command-line editing program (e.g. 'emacs', 'vi') + * @return $this + * + * @task config + */ + public function setFallbackEditor($editor) { + $this->fallback = $editor; + return $this; + } + + /** + * Get the name of the editor program to use. The value of the environmental + * variable $EDITOR will be used if available; otherwise, the best editor + * will be selected. + * + * @return string Command-line editing program. + * + * @task config + */ + public function getEditor() { + $editor = getenv('EDITOR'); + if (!$editor) { + $editor = $this->fallback; + } + + return $editor; + } +} diff --git a/src/console/editor/__init__.php b/src/console/editor/__init__.php new file mode 100644 index 0000000..ae1e1cf --- /dev/null +++ b/src/console/editor/__init__.php @@ -0,0 +1,12 @@ + $v) { + if ($v == '.' || $v == '..' || (!$include_hidden && $v[0] == '.')) { + unset($list[$k]); + } + } + + return array_values($list); + } + + + /** + * Return all directories between a path and "/". Iterating over them walks + * from the path to the root. + * + * @param string Path, absolute or relative to PWD. + * @return list List of parent paths, including the provided path. + * @task directory + */ + public static function walkToRoot($path) { + $path = self::resolvePath($path); + if ($path == '/') { + return array('/'); + } + + $walk = array(); + $parts = explode('/', $path); + foreach ($parts as $k => $part) { + if (!strlen($part)) { + unset($parts[$k]); + } + } + do { + $walk[] = '/'.implode('/', $parts); + if (empty($parts)) { + break; + } + array_pop($parts); + } while (true); + + return $walk; + } + + +/* -( Paths )-------------------------------------------------------------- */ + + + /** + * 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. + * + * @task path + */ + public static function resolvePath($path, $relative_to = null) { + if (strncmp($path, '/', 1)) { + if (!$relative_to) { + $relative_to = $_SERVER['PWD']; + } + $path = $relative_to.'/'.$path; + } + + if (is_link($path)) { + $parent_realpath = realpath(dirname($path)); + if ($parent_realpath !== false) { + return $parent_realpath.'/'.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. + $parts = explode('/', trim($path, '/')); + while (end($parts) !== false) { + array_pop($parts); + $attempt = '/'.implode('/', $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. + * @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 + * relative to. + * @return string Human-readable path. + * + * @task path + */ + public static function readablePath($path, $pwd = null) { + if ($pwd === null) { + $pwd = $_SERVER['PWD']; + } + + foreach (array($pwd, self::resolvePath($pwd)) as $parent) { + $parent = rtrim($parent, '/').'/'; + $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. + * @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 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. + * @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 = Filesystem::resolvePath($u); + $v = Filesystem::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); + } + + +/* -( Assert )------------------------------------------------------------- */ + + /** + * Assert that something (e.g., a file, directory, or symlink) is an + * absolute path to the specified location. + * + * @param string Assert that this path is absolute. + * @return void + * + * @task assert + */ + public static function assertAbsolute($path) { + if (empty($path) || $path[0] != '/') { + throw new FilesystemException( + $path, + "Filesystem entity `{$path}' is not absolute."); + } + } + + /** + * Assert that something (e.g., a file, directory, or symlink) exists at a + * specified location. + * + * @param string Assert that this path exists. + * @return void + * + * @task assert + */ + public static function assertExists($path) { + if (!self::pathExists($path)) { + throw new FilesystemException( + $path, + "Filesystem entity `{$path}' does not exist."); + } + } + + + /** + * Assert that nothing exists at a specified location. + * + * @param string 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, + "Path `{$path}' already exists!"); + } + } + + + /** + * Assert that a path represents a file, strictly (i.e., not a directory). + * + * @param string Assert that this path is a file. + * @return void + * + * @task assert + */ + public static function assertIsFile($path) { + if (!is_file($path)) { + throw new FilesystemException( + $path, + "Requested path `{$path}' is not a file."); + } + } + + + /** + * Assert that a path represents a directory, strictly (i.e., not a file). + * + * @param string Assert that this path is a directory. + * @return void + * + * @task assert + */ + public static function assertIsDirectory($path) { + if (!is_dir($path)) { + throw new FilesystemException( + $path, + "Requested path `{$path}' is not a directory."); + } + } + + + /** + * Assert that a file or directory exists and is writable. + * + * @param string Assert that this path is writable. + * @return void + * + * @task assert + */ + public static function assertWritable($path) { + if (!is_writable($path)) { + throw new FilesystemException( + $path, + "Requested path `{$path}' is not writable."); + } + } + + + /** + * Assert that a file or directory exists and is readable. + * + * @param string Assert that this path is readable. + * @return void + * + * @task assert + */ + public static function assertReadable($path) { + if (!is_readable($path)) { + throw new FilesystemException( + $path, + "Path `{$path}' is not readable."); + } + } +} diff --git a/src/filesystem/FilesystemException.php b/src/filesystem/FilesystemException.php new file mode 100644 index 0000000..ff8d6ad --- /dev/null +++ b/src/filesystem/FilesystemException.php @@ -0,0 +1,50 @@ +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/__init__.php b/src/filesystem/__init__.php new file mode 100644 index 0000000..758ebbf --- /dev/null +++ b/src/filesystem/__init__.php @@ -0,0 +1,14 @@ +root = $root; + } + + public function excludePath($path) { + $this->exclude[] = $path; + return $this; + } + + public function withSuffix($suffix) { + $this->suffix[] = '*.'.$suffix; + return $this; + } + + public function withPath($path) { + $this->paths[] = $path; + return $this; + } + + public function withType($type) { + $this->type = $type; + return $this; + } + + public function setGenerateChecksums($generate) { + $this->generateChecksums = $generate; + return $this; + } + + public function find() { + $args = array(); + $command = array(); + + $command[] = '(cd %s; '; + $args[] = $this->root; + + $command[] = 'find .'; + + if ($this->exclude) { + $command[] = $this->generateList('path', $this->exclude).' -prune'; + $command[] = '-o'; + } + + if ($this->type) { + $command[] = '-type %s'; + $args[] = $this->type; + } + + if ($this->suffix) { + $command[] = $this->generateList('name', $this->suffix); + } + + if ($this->paths) { + $command[] = $this->generateList('wholename', $this->paths); + } + + $command[] = '-print0'; + + if ($this->generateChecksums) { + static $md5sum_binary = null; + if ($md5sum_binary == null) { + + $options = array( + 'md5sum' => 'md5sum', + 'md5' => 'md5 -r', + ); + foreach ($options as $bin => $choose) { + list($err) = exec_manual('which %s', $bin); + if ($err == 0) { + $md5sum_binary = $choose; + break; + } + } + if ($md5sum_binary === null) { + throw new Exception( + "Unable to locate the md5/md5sum binary for this system."); + } + } + $command[] = ' | xargs -0 -n512 '.$md5sum_binary; + } + + $command[] = ')'; + + list($stdout) = call_user_func_array( + 'execx', + array_merge( + array(implode(' ', $command)), + $args)); + + if (!$this->generateChecksums) { + return explode("\0", trim($stdout)); + } else { + $stdout = trim($stdout); + $map = array(); + foreach (explode("\n", $stdout) as $line) { + $file = substr($line, 34); + if ($file == '-') { + continue; + } + // This mess is to make this class work on both mainline Linux systems + // and OSX, which has subtly different 'find' semantics. + $file = $this->root.ltrim($file, '.'); + $map[$file] = substr($line, 0, 32); + } + return $map; + } + } + + protected function generateList($flag, array $items) { + $items = array_map('escapeshellarg', $items); + foreach ($items as $key => $item) { + $items[$key] = '-'.$flag.' '.$item; + } + $items = implode(' -o ', $items); + return '\\( '.$items.' \\)'; + } +} + diff --git a/src/filesystem/filefinder/__init__.php b/src/filesystem/filefinder/__init__.php new file mode 100644 index 0000000..81a048a --- /dev/null +++ b/src/filesystem/filefinder/__init__.php @@ -0,0 +1,12 @@ +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. + * @return bool If true, the file is in the list. + */ + public function contains($path, $allow_parent_directory = true) { + + if ($this->isEmpty()) { + return true; + } + + $path = Filesystem::resolvePath($path); + if (is_dir($path)) { + $path .= '/'; + } + + 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. + */ + public function isEmpty() { + return !$this->files; + } + +} diff --git a/src/filesystem/filelist/__init__.php b/src/filesystem/filelist/__init__.php new file mode 100644 index 0000000..7a542ef --- /dev/null +++ b/src/filesystem/filelist/__init__.php @@ -0,0 +1,21 @@ +dir = Filesystem::createTemporaryDirectory(); + if ($filename === null) { + $this->file = tempnam($this->dir, getmypid().'-'); + } else { + $this->file = $this->dir.'/'.$filename; + } + + Filesystem::writeFile($this, ''); + } + + public function __toString() { + return $this->file; + } + + public function __destruct() { + Filesystem::remove($this->dir); + // Note that the function tempnam() doesn't guarantee it will return a + // file inside the dir you passed to the function. + @unlink($this->file); + } + + public function preserve() { + $this->destroy = false; + } +} diff --git a/src/filesystem/tempfile/__init__.php b/src/filesystem/tempfile/__init__.php new file mode 100644 index 0000000..c4e7a89 --- /dev/null +++ b/src/filesystem/tempfile/__init__.php @@ -0,0 +1,20 @@ +checkException(); + if ($this->isReady()) { + break; + } + + $read = $this->getReadSockets(); + $write = $this->getWriteSockets(); + + if ($timeout !== null) { + $elapsed = microtime(true) - $start; + if ($elapsed > $timeout) { + $this->checkException(); + return null; + } else { + $wait = $timeout - $elapsed; + } + } + + if ($read || $write) { + self::waitForSockets($read, $write, $wait); + } + } while (true); + + $this->checkException(); + return $this->getResult(); + } + + public function setException(Exception $ex) { + $this->exception = $ex; + return $this; + } + + public function getException() { + return $this->exception; + } + + + /** + * If an exception was set by setException(), throw it. + */ + private function checkException() { + if ($this->exception) { + throw $this->exception; + } + } + + + /** + * Retrieve a list of sockets which we can wait to become readable while + * a future is resolving. If your future has sockets which can be select()ed, + * return them here (or in getWriteSockets()) to make the resolve loop do a + * select(). If you do not return sockets in either case, you'll get a busy + * wait. + * + * @return list A list of sockets which we expect to become readable. + */ + public function getReadSockets() { + return array(); + } + + + /** + * Retrieve a list of sockets which we can wait to become writable while a + * future is resolving. See getReadSockets(). + * + * @return list A list of sockets which we expect to become writable. + */ + public function getWriteSockets() { + return array(); + } + + + /** + * 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. + * @return void + */ + public static function waitForSockets( + array $read_list, + array $write_list, + $timeout = 1) { + if (!self::$handlerInstalled) { + // 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('Future', 'handleSIGCHLD'))) { + throw new Exception('Failed to install signal handler!'); + } + } + self::$handlerInstalled = true; + } + + $timeout_sec = (int)$timeout; + $timeout_usec = (int)(1000000 * ($timeout - $timeout_sec)); + + $exceptfds = array(); + $ok = @stream_select( + $read_list, + $write_list, + $exceptfds, + $timeout, + $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. + } + + + /** + * Retrieve the final result of the future. This method will be called after + * the future is ready (as per isReady()) but before results are passed back + * to the caller. The major use of this function is that you can override it + * in subclasses to do postprocessing or error checking, which is + * particularly useful if building application-specific futures on top of + * primitive transport futures (like CurlFuture and ExecFuture) which can + * make it tricky to hook this logic into the main pipeline. + * + * @return mixed Final resolution of this future. + */ + protected function getResult() { + return $this->result; + } + + public function start() { + $this->isReady(); + } +} diff --git a/src/future/FutureIterator.php b/src/future/FutureIterator.php new file mode 100644 index 0000000..50dfb15 --- /dev/null +++ b/src/future/FutureIterator.php @@ -0,0 +1,251 @@ + new ExecFuture('wc -c a.txt'), + * 'b.txt' => new ExecFuture('wc -c b.txt'), + * 'c.txt' => new ExecFuture('wc -c c.txt'), + * ); + * foreach (Futures($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($stdout) = $future->resolvex(); + * do_some_processing($stdout); + * } + * + * + */ +class FutureIterator implements Iterator { + + protected $wait = array(); + protected $work = array(); + protected $futures = array(); + protected $key; + + protected $limit; + + protected $timeout; + protected $isTimeout = false; + + public function __construct(array $futures) { + foreach ($futures as $future) { + if (!$future instanceof Future) { + throw new Exception('Futures must all be objects implementing Future.'); + } + } + $this->futures = $futures; + } + + /** + * 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: + * + * foreach (Futures($futures)->setUpdateInterval(1) 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. + * @return this + */ + public function setUpdateInterval($interval) { + $this->timeout = $interval; + return $this; + } + + public function rewind() { + $this->wait = array_keys($this->futures); + $this->work = null; + $this->updateWorkingSet(); + $this->next(); + } + + protected function getWorkingSet() { + if ($this->work === null) { + return $this->wait; + } + + return $this->work; + } + + protected function updateWorkingSet() { + if (!$this->limit) { + $this->work = null; + } + + $old = $this->work; + $this->work = array_slice($this->wait, 0, $this->limit, true); + + // If we're using a limit, our futures are sleeping and need to be polled + // to begin execution, so poll any futures which weren't in our working set + // before. + foreach ($this->work as $work => $key) { + if (!isset($old[$work])) { + $this->futures[$key]->isReady(); + } + } + } + + public function next() { + $this->key = null; + if (!count($this->wait)) { + return; + } + + $read_sokcets = array(); + $write_sockets = array(); + + $start = microtime(true); + $wait_time = 1; + $timeout = $this->timeout; + $this->isTimeout = false; + + $check = $this->getWorkingSet(); + $resolve = null; + do { + $read_sockets = array(); + $write_sockets = array(); + $can_use_sockets = true; + foreach ($check as $wait => $key) { + $future = $this->futures[$key]; + try { + if ($future->getException()) { + $resolve = $wait; + continue; + } + if ($future->isReady()) { + if ($resolve === null) { + $resolve = $wait; + } + continue; + } + + $got_sockets = false; + $socks = $future->getReadSockets(); + if ($socks) { + $got_sockets = true; + foreach ($socks as $socket) { + $read_sockets[] = $socket; + } + } + + $socks = $future->getWriteSockets(); + if ($socks) { + $got_sockets = true; + foreach ($socks as $socket) { + $write_sockets[] = $socket; + } + } + + // If any currently active future had neither read nor write sockets, + // we can't wait for the current batch of items using sockets. + if (!$got_sockets) { + $can_use_sockets = false; + } + } catch (Exception $ex) { + $this->futures[$key]->setException($ex); + $resolve = $wait; + break; + } + } + if ($resolve === null) { + if ($can_use_sockets) { + + if ($timeout !== null) { + $elapsed = microtime(true) - $start; + if ($elapsed > $timeout) { + $this->isTimeout = true; + return; + } else { + $wait_time = $timeout - $elapsed; + } + } + + Future::waitForSockets($read_sockets, $write_sockets, $wait_time); + } else { + usleep(1000); + } + } + } while ($resolve === null); + + $this->key = $this->wait[$resolve]; + unset($this->wait[$resolve]); + $this->updateWorkingSet(); + } + + + public function current() { + if ($this->isTimeout) { + return null; + } + return $this->futures[$this->key]; + } + + public function key() { + if ($this->isTimeout) { + return null; + } + return $this->key; + } + + public function valid() { + if ($this->isTimeout) { + return true; + } + return ($this->key !== null); + } + + public function resolveAll() { + foreach ($this as $_) { + // This implicitly forces all the futures to resolve. + } + } + + /** + * Limits the number of simultaneous tasks. + * + * @param int Maximum number of simultaneous jobs allowed. + * @return this + */ + public function limit($max) { + $this->limit = $max; + return $this; + } + +} diff --git a/src/future/__init__.php b/src/future/__init__.php new file mode 100644 index 0000000..7c15f3c --- /dev/null +++ b/src/future/__init__.php @@ -0,0 +1,20 @@ +command = $command; + $this->error = $error; + $this->stdout = $stdout; + $this->stderr = $stderr; + } + + public function getCommand() { + return $this->command; + } + + public function getError() { + return $this->error; + } + + public function getStdout() { + return $this->stdout; + } + + public function getStderr() { + return $this->stderr; + } +} + diff --git a/src/future/exec/ExecFuture.php b/src/future/exec/ExecFuture.php new file mode 100644 index 0000000..92c9806 --- /dev/null +++ b/src/future/exec/ExecFuture.php @@ -0,0 +1,358 @@ +resolve(); + * + * // ...or, throw on nonzero exit with 'resolvex()'. + * list($stdout, $stderr) = $future->resolvex(); + */ + +class ExecFuture extends Future { + + const TIMED_OUT_EXIT_CODE = 142; + + protected $pipes = array(); + protected $proc = null; + protected $start = null; + protected $timeout = null; + + protected $stdout = null; + protected $stderr = null; + protected $stdin = null; + protected $closePipe = false; + + protected $stdoutPos = 0; + protected $stderrPos = 0; + protected $command = null; + + protected $stdoutSizeLimit = PHP_INT_MAX; + protected $stderrSizeLimit = PHP_INT_MAX; + + protected static $echoMode = array(); + protected static $descriptorSpec = array( + 0 => array('pipe', 'r'), // stdin + 1 => array('pipe', 'w'), // stdout + 2 => array('pipe', 'w'), // stderr + ); + + + public static function pushEchoMode($mode) { + self::$echoMode[] = $mode; + } + + public static function popEchoMode() { + array_pop(self::$echoMode); + } + + public static function peekEchoMode() { + return end(self::$echoMode); + } + + public function setStdoutSizeLimit($limit) { + $this->stdoutSizeLimit = $limit; + } + + public function getStdoutSizeLimit() { + return $this->stdoutSizeLimit; + } + + public function setStderrSizeLimit($limit) { + $this->stderrSizeLimit = $limit; + } + + public function getStderrSizeLimit() { + return $this->stderrSizeLimit; + } + + public function __construct($command) { + $argv = func_get_args(); + $this->command = call_user_func_array('csprintf', $argv); + } + + public function __destruct() { + 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; + } + + public function getCommand() { + return $this->command; + } + + public function read() { + if ($this->start) { + $this->isReady(); // Sync + } + + $result = array( + (string)substr($this->stdout, $this->stdoutPos), + (string)substr($this->stderr, $this->stderrPos), + ); + + $this->stdoutPos = strlen($this->stdout); + $this->stderrPos = strlen($this->stderr); + + return $result; + } + + public function write($data, $keep_pipe = false) { + $this->stdin .= $data; + + if (!$keep_pipe) { + $this->closePipe = true; + } + + if ($this->start) { + $this->isReady(); // Sync + } + + return $this; + } + + 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; + } + + /** + * Reads some bytes from a stream, discarding output once a certain amount + * has been accumulated. + * + * @param resource $stream + * Stream to read from. + * @param int $limit + * Maximum number of bytes to return from $stream. If additional bytes + * are available, they will be read and discarded. + * @param string $description + * Human-readable description of stream, for exception message. + * @return string + * The data read from the stream. + * @throws Exception + * The stream was not readable. + */ + protected function readAndDiscard($stream, $limit, $description) { + $output = ''; + + do { + $data = fread($stream, 4096); + if (false === $data) { + throw new Exception('Failed to read from '.$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); + } + } while ($read_bytes > 0); + + return $output; + } + + public function getWriteSockets() { + list($stdin, $stdout, $stderr) = $this->pipes; + $sockets = array(); + if (isset($stdin) && strlen($this->stdin) && !feof($stdin)) { + $sockets[] = $stdin; + } + return $sockets; + } + + public function isReady() { + + if (!$this->pipes) { + + if (self::peekEchoMode()) { + echo " >>> \$ {$this->command}\n"; + } + + $pipes = array(); + $proc = proc_open($this->command, self::$descriptorSpec, $pipes); + if (!is_resource($proc)) { + throw new Exception('Failed to open process.'); + } + + $this->start = time(); + $this->pipes = $pipes; + $this->proc = $proc; + + list($stdin, $stdout, $stderr) = $pipes; + + if ((!stream_set_blocking($stdout, false)) || + (!stream_set_blocking($stderr, false)) || + (!stream_set_blocking($stdin, false))) { + $this->__destruct(); + throw new Exception('Failed to set streams nonblocking.'); + } + + return false; + } + + if (!$this->proc) { + return true; + } + + list($stdin, $stdout, $stderr) = $this->pipes; + + if (isset($this->stdin) && strlen($this->stdin)) { + $bytes = fwrite($stdin, $this->stdin); + if ($bytes === false) { + throw new Exception('Unable to write to stdin!'); + } else if ($bytes) { + $this->stdin = substr($this->stdin, $bytes); + } + + if (!strlen($this->stdin) && $this->closePipe) { + @fclose($stdin); + $this->pipes[0] = null; + } + } else { + // make sure to remove any references to the pipe in the case when stdin + // length was zero. avoid the overhead of calling fclose() as it is not + // necessary. + $this->pipes[0] = null; + } + + // Read status before reading pipes so that we can never miss data that + // arrives between our last read and the process exiting. + $status = proc_get_status($this->proc); + + $this->stdout .= $this->readAndDiscard( + $stdout, + $this->getStdoutSizeLimit() - strlen($this->stdout), + 'stdout'); + $this->stderr .= $this->readAndDiscard( + $stderr, + $this->getStderrSizeLimit() - strlen($this->stderr), + 'stderr'); + + if (!$status['running']) { + $this->result = array( + $status['exitcode'], + $this->stdout, + $this->stderr, + ); + $this->__destruct(); + return true; + } + + if ($this->timeout && ((time() - $this->start) >= $this->timeout)) { + if (defined('SIGKILL')) { + $signal = SIGKILL; + } else { + $signal = 9; + } + proc_terminate($this->proc, $signal); + $this->result = array( + self::TIMED_OUT_EXIT_CODE, + $this->stdout, + $this->stderr."\n". + "(This process was prematurely terminated by timeout.)"); + $this->__destruct(); + return true; + } + + } + + public function setTimeout($seconds) { + $this->timeout = $seconds; + return $this; + } + + public function resolve($timeout = null) { + if (null === $timeout) { + $timeout = $this->timeout; + } + return parent::resolve($timeout); + } + + public function resolvex($timeout = null) { + list($err, $stdout, $stderr) = $this->resolve($timeout); + if ($err) { + $cmd = $this->command; + throw new CommandException( + "Command '{$cmd}' failed with error #{$err}:\n". + "stdout:\n{$stdout}\n". + "stderr:\n{$stderr}\n", + $cmd, + $err, + $stdout, + $stderr); + } + return array($stdout, $stderr); + } + + public function resolveJSON($timeout = null) { + list($stdout, $stderr) = $this->resolvex($timeout); + if (strlen($stderr)) { + $cmd = $this->command; + throw new CommandException( + "JSON command '{$cmd}' emitted text to stderr when none was expected: ". + $stderr, + $cmd, + 0, + $stdout, + $stderr); + } + $object = json_decode($stdout, true); + if (!is_array($object)) { + $cmd = $this->command; + throw new CommandException( + "JSON command '{$cmd}' did not produce a valid JSON object on stdout: ". + $stdout, + $cmd, + 0, + $stdout, + $stderr); + } + return $object; + } +} diff --git a/src/future/exec/__init__.php b/src/future/exec/__init__.php new file mode 100644 index 0000000..3ead0d7 --- /dev/null +++ b/src/future/exec/__init__.php @@ -0,0 +1,16 @@ +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 execx() instead, + * which throws an exception when it encounters an error. + * + * @param string 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(); + + if (ExecFuture::peekEchoMode()) { + ExecFuture::pushEchoMode(false); + + echo " >>> \$ {$cmd} ... "; + $t_start = microtime(true); + + $ef = newv('ExecFuture', $args); + $result = $ef->resolve(); + + $t_end = microtime(true); + $duration = number_format((int)(1000 * ($t_end - $t_start))).' ms'; + echo " {$duration}\n"; + + ExecFuture::popEchoMode(); + } else { + $ef = newv('ExecFuture', $args); + $result = $ef->resolve(); + } + + return $result; +} diff --git a/src/future/http/HTTPFuture.php b/src/future/http/HTTPFuture.php new file mode 100644 index 0000000..074cc4c --- /dev/null +++ b/src/future/http/HTTPFuture.php @@ -0,0 +1,360 @@ +resolve(); + */ +class HTTPFuture extends Future { + + /** Remote host returned something other than an HTTP response. */ + const ERROR_MALFORMED_RESPONSE = 1000; + + /** Future walltime exceeded the allowable walltime (see setTimeout()). */ + const ERROR_TIMEOUT = 1001; + + /** Connection was aborted before writing was complete. */ + const ERROR_CONNECTION_ABORTED = 1002; + + /** Connection was refused by remote host. */ + const ERROR_CONNECTION_REFUSED = 1003; + + /** Connection failed entirely, hostname is probably invalid. */ + const ERROR_CONNECTION_FAILED = 1004; + + protected $method = 'GET'; + protected $uri; + protected $data; + protected $host; + protected $port = 80; + protected $timeout = 30.0; + /** + * Whether to log debugging output to scribe. Hopefully, this will be a + * short-term measure. + */ + private $debug = false; + /** + * A string intended to uniquely identify this HTTPFuture for debugging + * purposes. + */ + private $debugFutureID = null; + + protected $socket; + protected $writeBuffer; + protected $response; + protected $headers = array(); + + protected $stateConnected = false; + protected $stateWriteComplete = false; + protected $stateReady = false; + protected $stateStartTime; + + public function __construct($uri, array $data = array()) { + $this->data = $data; + + $parts = parse_url($uri); + if (!$parts) { + throw new Exception("Could not parse URI '{$uri}'."); + } + + if (empty($parts['scheme']) || $parts['scheme'] !== 'http') { + throw new Exception( + "URI '{$uri}' must be fully qualified with 'http://' scheme."); + } + + if (!isset($parts['host'])) { + throw new Exception( + "URI '{$uri}' must be fully qualified and include host name."); + } + + $this->host = $parts['host']; + $this->headers['Host'] = $this->host; + + if (!empty($parts['port'])) { + $this->port = $parts['port']; + } + + if (isset($parts['user']) || isset($parts['pass'])) { + throw new Exception( + "HTTP Basic Auth is not supported by HTTPFuture."); + } + + if (isset($parts['path'])) { + $this->uri = $parts['path']; + } else { + $this->uri = '/'; + } + + if (isset($parts['query'])) { + $this->uri .= '?'.$parts['query']; + } + + } + + public function getURI() { + return $this->uri; + } + + public function setHeader($header, $value) { + $this->headers[$header] = $value; + return $this; + } + + public function setTimeout($timeout) { + $this->timeout = $timeout; + return $this; + } + + public function setMethod($method) { + if ($method !== 'GET' && $method !== 'POST') { + throw new Exception('Only GET and POST are supported HTTP methods.'); + } + $this->method = $method; + return $this; + } + + public function __destruct() { + if ($this->socket) { + @fclose($this->socket); + $this->socket = null; + } + } + + public function getReadSockets() { + if ($this->socket) { + return array($this->socket); + } + return array(); + } + + public function getWriteSockets() { + if (strlen($this->writeBuffer)) { + return array($this->socket); + } + return array(); + } + + public function getDefaultUserAgent() { + return 'HTTPFuture/1.0'; + } + + public function isReady() { + if ($this->stateReady) { + return true; + } + + if (!$this->socket) { + $this->stateStartTime = microtime(true); + $this->socket = $this->buildSocket(); + if (!$this->socket) { + return $this->stateReady; + } + } + + if (!$this->stateConnected) { + $read = array(); + $write = array($this->socket); + $except = array(); + $select = stream_select($read, $write, $except, $tv_sec = 0); + if ($write) { + $this->stateConnected = true; + } + } + + if ($this->stateConnected) { + if (strlen($this->writeBuffer)) { + $bytes = @fwrite($this->socket, $this->writeBuffer); + if ($bytes === false) { + throw new Exception("Failed to write to buffer."); + } else if ($bytes) { + $this->writeBuffer = substr($this->writeBuffer, $bytes); + } + } + + if (!strlen($this->writeBuffer)) { + $this->stateWriteComplete = true; + } + + while (($data = fread($this->socket, 32768)) || strlen($data)) { + $this->response .= $data; + } + + if ($data === false) { + throw new Exception("Failed to read socket."); + } + } + + return $this->checkSocket(); + } + + protected function buildSocket() { + + $errno = null; + $errstr = null; + $socket = @stream_socket_client( + 'tcp://'.$this->host.':'.$this->port, + $errno, + $errstr, + $ignored_connection_timeout = 1.0, + STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT); + + if (!$socket) { + $this->stateReady = true; + $this->result = $this->buildErrorResult(self::ERROR_CONNECTION_FAILED); + return null; + } + + $ok = stream_set_blocking($socket, 0); + if (!$ok) { + throw new Exception("Failed to set stream nonblocking."); + } + + $this->writeBuffer = $this->buildHTTPRequest(); + + return $socket; + } + + protected function checkSocket() { + + $timeout = false; + $now = microtime(true); + if ($now - $this->stateStartTime > $this->timeout) { + $timeout = true; + } + + if (!feof($this->socket) && !$timeout) { + return false; + } + + if ($timeout) { + $result = $this->buildErrorResult(self::ERROR_TIMEOUT); + } else if (!$this->stateConnected) { + $result = $this->buildErrorResult(self::ERROR_CONNECTION_REFUSED); + } else if (!$this->stateWriteComplete) { + $result = $this->buildErrorResult(self::ERROR_CONNECTION_FAILED); + } else { + $result = $this->parseHTTPResponse($this->response); + } + + $this->result = $result; + $this->stateReady = true; + + return true; + } + + protected function buildErrorResult($error) { + return array($error, null, array()); + } + + protected function parseHTTPResponse($response) { + + static $rex_base = "@^(?.*?)\r?\n\r?\n(?.*)$@s"; + static $rex_head = "@^HTTP/\S+ (?\d+) .*?(?:\r?\n(?.*))?$@s"; + static $rex_header = '@^(?.*?):\s*(?.*)$@'; + + static $malformed = array( + self::ERROR_MALFORMED_RESPONSE, + null, + array(), + ); + + $matches = null; + if (!preg_match($rex_base, $response, $matches)) { + return $malformed; + } + + $head = $matches['head']; + $body = $matches['body']; + + if (!preg_match($rex_head, $head, $matches)) { + return $malformed; + } + + $response_code = (int)$matches['code']; + + $headers = array(); + if (isset($matches['headers'])) { + $head_raw = $matches['headers']; + if (strlen($head_raw)) { + $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 array( + $response_code, + $body, + $headers, + ); + } + + public function buildHTTPRequest() { + $data = http_build_query($this->data); + $length = strlen($data); + + $force_headers = array(); // These are always sent. + $default_headers = array(); // These can be overridden with setHeader(). + + $uri = $this->uri; + if ($this->method == 'GET') { + if ($data) { + if (strpos($uri, '?') !== false) { + $uri .= '&'.$data; + } else { + $uri .= '?'.$data; + } + } + $data = null; + $length = 0; + } else { + $force_headers['Content-Type'] = 'application/x-www-form-urlencoded'; + $data .= "\r\n"; + } + + $force_headers['Content-Length'] = $length; + $default_headers['User-Agent'] = $this->getDefaultUserAgent(); + + $headers = $force_headers + $this->headers + $default_headers; + + foreach ($headers as $key => $header) { + $headers[$key] = $key.': '.$header."\r\n"; + } + + return + "{$this->method} {$uri} HTTP/1.0\r\n". + implode('', $headers). + "\r\n". + $data; + } + +} diff --git a/src/future/http/__init__.php b/src/future/http/__init__.php new file mode 100644 index 0000000..ad5947c --- /dev/null +++ b/src/future/http/__init__.php @@ -0,0 +1,21 @@ +config[$key] = $value; + return $this; + } + + public function getConfig($key, $default = null) { + return idx($this->config, $key, $default); + } + + public function setBlockRules(array $rules) { + $this->blockRules = $rules; + return $this; + } + + public function storeText($text) { + return $this->storage->store($text); + } + + public function markupText($text) { + $this->storage = new PhutilRemarkupBlockStorage(); + + $block_rules = $this->blockRules; + if (empty($block_rules)) { + throw new Exception("Remarkup engine not configured with block rules."); + } + foreach ($block_rules as $rule) { + $rule->setEngine($this); + } + + // Apply basic block and paragraph normalization to the text. + $text = preg_replace("/\r\n?/", "\n", $text); + $text = preg_replace("/[ \t]*$/m", '', $text); + $text = preg_split("/\n\n/", $text); + + $blocks = array(); + $last = null; + foreach ($text as $block) { + $match = false; + foreach ($block_rules as $key => $block_rule) { + $pattern = $block_rule->getBlockPattern(); + if (!preg_match($pattern, trim($block, "\n"))) { + continue; + } + if (($last !== null) && + ($key == $last) && + $block_rule->shouldMergeBlocks()) { + end($blocks); + $last_block_key = key($blocks); + $blocks[$last_block_key]['block'] .= "\n\n".$block; + } else { + $blocks[] = array( + 'rule' => $block_rule, + 'block' => $block, + ); + } + $last = $key; + $match = true; + break; + } + if (!$match) { + throw new Exception("Block in text did not match any block rule."); + } + } + + $output = array(); + foreach ($blocks as $block) { + $output[] = $block['rule']->markupText($block['block']); + } + + $output = implode("\n\n", $output); + $output = $this->storage->restore($output); + + unset($this->storage); + + return $output; + } +} diff --git a/src/markup/engine/remarkup/__init__.php b/src/markup/engine/remarkup/__init__.php new file mode 100644 index 0000000..1983e82 --- /dev/null +++ b/src/markup/engine/remarkup/__init__.php @@ -0,0 +1,14 @@ + & " +~~~~~~~~~~ +

< > & "

\ No newline at end of file diff --git a/src/markup/engine/remarkup/__tests__/data/leading-newline.txt b/src/markup/engine/remarkup/__tests__/data/leading-newline.txt new file mode 100644 index 0000000..f8ba33b --- /dev/null +++ b/src/markup/engine/remarkup/__tests__/data/leading-newline.txt @@ -0,0 +1,4 @@ + +a +~~~~~~~~~~ +

a

\ No newline at end of file diff --git a/src/markup/engine/remarkup/__tests__/data/link-brackets.txt b/src/markup/engine/remarkup/__tests__/data/link-brackets.txt new file mode 100755 index 0000000..407d1d4 --- /dev/null +++ b/src/markup/engine/remarkup/__tests__/data/link-brackets.txt @@ -0,0 +1,3 @@ + +~~~~~~~~~~ +

http://www.zany.com/omg/space url/

\ No newline at end of file diff --git a/src/markup/engine/remarkup/__tests__/data/link-with-punctuation.txt b/src/markup/engine/remarkup/__tests__/data/link-with-punctuation.txt new file mode 100644 index 0000000..6d6f612 --- /dev/null +++ b/src/markup/engine/remarkup/__tests__/data/link-with-punctuation.txt @@ -0,0 +1,5 @@ +http://www.example.com/, +http://www.example.com/.. +http://www.example.com/!!! +~~~~~~~~~~ +

http://www.example.com/,
http://www.example.com/..
http://www.example.com/!!!

\ No newline at end of file diff --git a/src/markup/engine/remarkup/__tests__/data/link-with-tilde.txt b/src/markup/engine/remarkup/__tests__/data/link-with-tilde.txt new file mode 100644 index 0000000..c15a9a7 --- /dev/null +++ b/src/markup/engine/remarkup/__tests__/data/link-with-tilde.txt @@ -0,0 +1,3 @@ +http://www.example.com/~ +~~~~~~~~~~ +

http://www.example.com/~

\ No newline at end of file diff --git a/src/markup/engine/remarkup/__tests__/data/link.txt b/src/markup/engine/remarkup/__tests__/data/link.txt new file mode 100755 index 0000000..c48fba9 --- /dev/null +++ b/src/markup/engine/remarkup/__tests__/data/link.txt @@ -0,0 +1,3 @@ +http://www.example.com/ +~~~~~~~~~~ +

http://www.example.com/

\ No newline at end of file diff --git a/src/markup/engine/remarkup/__tests__/data/list.txt b/src/markup/engine/remarkup/__tests__/data/list.txt new file mode 100755 index 0000000..c1c2ff7 --- /dev/null +++ b/src/markup/engine/remarkup/__tests__/data/list.txt @@ -0,0 +1,8 @@ + - < > & " + +text block +~~~~~~~~~~ +
    +
  • < > & "
  • +
+

text block

\ No newline at end of file diff --git a/src/markup/engine/remarkup/__tests__/data/monospaced.txt b/src/markup/engine/remarkup/__tests__/data/monospaced.txt new file mode 100644 index 0000000..aed134a --- /dev/null +++ b/src/markup/engine/remarkup/__tests__/data/monospaced.txt @@ -0,0 +1,3 @@ +##ls --color > /dev/null## +~~~~~~~~~~ +

ls --color > /dev/null

\ No newline at end of file diff --git a/src/markup/engine/remarkup/__tests__/data/simple.txt b/src/markup/engine/remarkup/__tests__/data/simple.txt new file mode 100755 index 0000000..d7ed317 --- /dev/null +++ b/src/markup/engine/remarkup/__tests__/data/simple.txt @@ -0,0 +1,3 @@ +hello +~~~~~~~~~~ +

hello

\ No newline at end of file diff --git a/src/markup/engine/remarkup/blockrule/base/PhutilRemarkupEngineBlockRule.php b/src/markup/engine/remarkup/blockrule/base/PhutilRemarkupEngineBlockRule.php new file mode 100644 index 0000000..4281164 --- /dev/null +++ b/src/markup/engine/remarkup/blockrule/base/PhutilRemarkupEngineBlockRule.php @@ -0,0 +1,57 @@ +engine = $engine; + return $this; + } + + final protected function getEngine() { + return $this->engine; + } + + public function setMarkupRules(array $rules) { + $this->rules = $rules; + return $this; + } + + final private function getMarkupRules() { + return $this->rules; + } + + final protected function applyRules($text) { + $engine = $this->getEngine(); + foreach ($this->getMarkupRules() as $rule) { + $rule->setEngine($engine); + $text = $rule->apply($text); + } + return $text; + } + + +} diff --git a/src/markup/engine/remarkup/blockrule/base/__init__.php b/src/markup/engine/remarkup/blockrule/base/__init__.php new file mode 100644 index 0000000..c3938a7 --- /dev/null +++ b/src/markup/engine/remarkup/blockrule/base/__init__.php @@ -0,0 +1,10 @@ +'.$this->applyRules($text).'
'; + } +} diff --git a/src/markup/engine/remarkup/blockrule/remarkupcode/__init__.php b/src/markup/engine/remarkup/blockrule/remarkupcode/__init__.php new file mode 100644 index 0000000..4758cf9 --- /dev/null +++ b/src/markup/engine/remarkup/blockrule/remarkupcode/__init__.php @@ -0,0 +1,12 @@ + $line) { + $lines[$key] = $this->applyRules($line."\n"); + } + return '

'.implode('', $lines).'

'; + } + +} diff --git a/src/markup/engine/remarkup/blockrule/remarkupdefault/__init__.php b/src/markup/engine/remarkup/blockrule/remarkupdefault/__init__.php new file mode 100644 index 0000000..41b7bf2 --- /dev/null +++ b/src/markup/engine/remarkup/blockrule/remarkupdefault/__init__.php @@ -0,0 +1,12 @@ +'.$this->applyRules($text).''; + } + +} diff --git a/src/markup/engine/remarkup/blockrule/remarkupheader/__init__.php b/src/markup/engine/remarkup/blockrule/remarkupheader/__init__.php new file mode 100644 index 0000000..1692dae --- /dev/null +++ b/src/markup/engine/remarkup/blockrule/remarkupheader/__init__.php @@ -0,0 +1,12 @@ +applyRules($text); + } + +} diff --git a/src/markup/engine/remarkup/blockrule/remarkupinline/__init__.php b/src/markup/engine/remarkup/blockrule/remarkupinline/__init__.php new file mode 100644 index 0000000..4626563 --- /dev/null +++ b/src/markup/engine/remarkup/blockrule/remarkupinline/__init__.php @@ -0,0 +1,12 @@ + $item) { + if (!strlen($item)) { + unset($items[$key]); + } else { + $items[$key] = '
  • '.$this->applyRules($item).'
  • '; + } + } + return ''; + } +} diff --git a/src/markup/engine/remarkup/blockrule/remarkuplist/__init__.php b/src/markup/engine/remarkup/blockrule/remarkuplist/__init__.php new file mode 100644 index 0000000..5762cac --- /dev/null +++ b/src/markup/engine/remarkup/blockrule/remarkuplist/__init__.php @@ -0,0 +1,12 @@ +index)."%"; + $this->map[$key] = $token; + return $key; + } + + public function restore($corpus) { + if ($this->map) { + $corpus = str_replace( + array_reverse(array_keys($this->map)), + array_reverse($this->map), + $corpus); + $this->map = array(); + } + return $corpus; + } + +} diff --git a/src/markup/engine/remarkup/blockstorage/__init__.php b/src/markup/engine/remarkup/blockstorage/__init__.php new file mode 100644 index 0000000..eb361b0 --- /dev/null +++ b/src/markup/engine/remarkup/blockstorage/__init__.php @@ -0,0 +1,10 @@ +engine = $engine; + return $this; + } + + public function getEngine() { + return $this->engine; + } + + abstract public function apply($text); +} diff --git a/src/markup/engine/remarkup/markuprule/base/__init__.php b/src/markup/engine/remarkup/markuprule/base/__init__.php new file mode 100644 index 0000000..02b4f10 --- /dev/null +++ b/src/markup/engine/remarkup/markuprule/base/__init__.php @@ -0,0 +1,10 @@ +\1', + $text); + } + +} diff --git a/src/markup/engine/remarkup/markuprule/bold/__init__.php b/src/markup/engine/remarkup/markuprule/bold/__init__.php new file mode 100644 index 0000000..cbc5425 --- /dev/null +++ b/src/markup/engine/remarkup/markuprule/bold/__init__.php @@ -0,0 +1,12 @@ +getEngine()->storeText($matches[0]); + } + +} diff --git a/src/markup/engine/remarkup/markuprule/escaperemarkup/__init__.php b/src/markup/engine/remarkup/markuprule/escaperemarkup/__init__.php new file mode 100644 index 0000000..b63e662 --- /dev/null +++ b/src/markup/engine/remarkup/markuprule/escaperemarkup/__init__.php @@ -0,0 +1,12 @@ +]@', + array($this, 'markupHyperlink'), + $text); + + $text = preg_replace_callback( + '@(?<=^|\s)(\w{3,}://\S+)(?=\s|$)@', + array($this, 'markupHyperlinkUngreedy'), + $text); + + return $text; + } + + public function markupHyperlink($matches) { + return $this->getEngine()->storeText( + phutil_render_tag( + 'a', + array( + 'href' => $matches[1], + 'target' => '_blank', + ), + phutil_escape_html($matches[1]))); + } + + public function markupHyperlinkUngreedy($matches) { + $match = $matches[1]; + $tail = null; + $trailing = null; + if (preg_match('/[;,.:!?]+$/', $match, $trailing)) { + $tail = $trailing[0]; + $match = substr($match, 0, -strlen($tail)); + } + return $this->markupHyperlink(array(null, $match)).$tail; + } + +} diff --git a/src/markup/engine/remarkup/markuprule/hyperlink/__init__.php b/src/markup/engine/remarkup/markuprule/hyperlink/__init__.php new file mode 100644 index 0000000..52e9a40 --- /dev/null +++ b/src/markup/engine/remarkup/markuprule/hyperlink/__init__.php @@ -0,0 +1,13 @@ +\1', + $text); + } + +} diff --git a/src/markup/engine/remarkup/markuprule/italics/__init__.php b/src/markup/engine/remarkup/markuprule/italics/__init__.php new file mode 100644 index 0000000..ae56d31 --- /dev/null +++ b/src/markup/engine/remarkup/markuprule/italics/__init__.php @@ -0,0 +1,12 @@ +\n", + $text); + } + +} diff --git a/src/markup/engine/remarkup/markuprule/linebreaks/__init__.php b/src/markup/engine/remarkup/markuprule/linebreaks/__init__.php new file mode 100644 index 0000000..01b48ac --- /dev/null +++ b/src/markup/engine/remarkup/markuprule/linebreaks/__init__.php @@ -0,0 +1,12 @@ +\1', + $text); + } + +} diff --git a/src/markup/engine/remarkup/markuprule/monospace/__init__.php b/src/markup/engine/remarkup/markuprule/monospace/__init__.php new file mode 100644 index 0000000..2411f43 --- /dev/null +++ b/src/markup/engine/remarkup/markuprule/monospace/__init__.php @@ -0,0 +1,12 @@ + $v) { + $v = phutil_escape_html($v); + $attributes[$k] = ' '.$k.'="'.$v.'"'; + } + + $attributes = implode('', $attributes); + + if ($content === null) { + return '<'.$tag.$attributes.' />'; + } else { + return '<'.$tag.$attributes.'>'.$content.''; + } +} + +function phutil_escape_html($string) { + return htmlspecialchars($string, ENT_QUOTES, 'UTF-8'); +} diff --git a/src/moduleutils/__init__.php b/src/moduleutils/__init__.php new file mode 100644 index 0000000..0d21216 --- /dev/null +++ b/src/moduleutils/__init__.php @@ -0,0 +1,12 @@ + $root) { + if (rtrim(Filesystem::resolvePath($root), '/') == $path) { + return $lib; + } + } + if ($loaded) { + return null; + } else { + phutil_load_library($path); + } + } while (true); +} diff --git a/src/utils/__init__.php b/src/utils/__init__.php new file mode 100644 index 0000000..d5139e8 --- /dev/null +++ b/src/utils/__init__.php @@ -0,0 +1,19 @@ + $object) { + if ($key_method !== null) { + $key = $object->$key_method(); + } + if ($method !== null) { + $value = $object->$method(); + } else { + $value = $object; + } + $result[$key] = $value; + } + return $result; +} + +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; +} + +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; +} + +/** + * Sort a list of objects by the return value of some method. + */ +function msort(array $list, $method) { + $surrogate = mpull($list, $method); + + asort($surrogate); + + $result = array(); + foreach ($surrogate as $key => $value) { + $result[$key] = $list[$key]; + } + + 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. + * @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; +} + +/** + * 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 coalesce(), but less strict: returns the first non-empty() + * argument, instead of the first argument that is strictly nonnull. If no + * argument is nonempty, it returns the last argument. This is useful + * idiomatically for setting defaults: + * + * $value = nonempty($get_value, 0); + * + * @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 pased in zero args. + */ +function nonempty(/* ... */) { + $args = func_get_args(); + foreach ($args as $arg) { + if ($arg) { + break; + } + } + return $arg; +} + +/** + * 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! Two popular + * alternatives are: + * + * - Build a fake serialized object and unserialize it. + * - Invoke the constructor twice. + * + * 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 + * construtors 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. + * + * @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); + return $reflector->newInstanceArgs($argv); +} + + diff --git a/src/xsprintf/__init__.php b/src/xsprintf/__init__.php new file mode 100644 index 0000000..baeaac9 --- /dev/null +++ b/src/xsprintf/__init__.php @@ -0,0 +1,19 @@ + $pos + 1) ? $pattern[$pos + 1] : null; + + switch ($type) { + case 'L': + // Only '%Ls' is supported. + if ($next !== 's') { + throw new Exception("Unknown conversion %L{$next}."); + } + + // Remove the L, leaving %s + $pattern = substr_replace($pattern, '', $pos, 1); + $length = strlen($pattern); + $type = 's'; + + // Check that the value is a non-empty array. + if (!is_array($value) || !$value) { + throw new Exception("Expected a non-empty array for %Ls conversion."); + } + + // Convert the list of strings to a single string. + $value = implode(' ', array_map('escapeshellarg', $value)); + break; + case 's': + $value = escapeshellarg($value); + $type = 's'; + break; + case 'C': + $type = 's'; + break; + } + $pattern[$pos] = $type; +} diff --git a/src/xsprintf/jsprintf/__init__.php b/src/xsprintf/jsprintf/__init__.php new file mode 100644 index 0000000..2da434f --- /dev/null +++ b/src/xsprintf/jsprintf/__init__.php @@ -0,0 +1,12 @@ + 0x1FFFFFFFFFFFFF) { + throw new Exception( + "You are passing an integer to jsprintf() which is so large it can ". + "not be represented without loss of precision by Javascript's ". + "native Number class. Use %# instead."); + } + break; + } + + $pattern[$pos] = $type; +} diff --git a/src/xsprintf/qsprintf/QsprintfQueryParameterException.php b/src/xsprintf/qsprintf/QsprintfQueryParameterException.php new file mode 100644 index 0000000..d02f4ea --- /dev/null +++ b/src/xsprintf/qsprintf/QsprintfQueryParameterException.php @@ -0,0 +1,32 @@ +query = $query; + } + + public function getQuery() { + return $this->query; + } + +} diff --git a/src/xsprintf/qsprintf/__init__.php b/src/xsprintf/qsprintf/__init__.php new file mode 100644 index 0000000..9fb0fff --- /dev/null +++ b/src/xsprintf/qsprintf/__init__.php @@ -0,0 +1,13 @@ + and %<. + * + * %> ("Prefix") + * Escapes a prefix query for a LIKE clause. For example: + * + * // Find all rows where `name` starts with $prefix. + * qsprintf($conn, 'WHERE name LIKE %>', $prefix); + * + * %< ("Suffix") + * Escapes a suffix query for a LIKE clause. For example: + * + * // Find all rows where `name` ends with $suffix. + * qsprintf($conn, 'WHERE name LIKE %<', $suffix); + */ +function qsprintf($conn, $pattern/*, ... */) { + $args = func_get_args(); + array_shift($args); + return xsprintf('xsprintf_query', $conn, $args); +} + +function vqsprintf($conn, $pattern, array $argv) { + array_unshift($argv, $pattern); + return xsprintf('xsprintf_query', $conn, $argv); +} + + +/** + * xsprintf() callback for encoding SQL queries. See qsprintf(). + */ +function xsprintf_query($userdata, &$pattern, &$pos, &$value, &$length) { + $type = $pattern[$pos]; + $conn = $userdata; + $next = (strlen($pattern) > $pos + 1) ? $pattern[$pos + 1] : null; + + $nullable = false; + $done = false; + + $prefix = ''; + + switch ($type) { + case '=': // Nullable test + switch ($next) { + case 'd': + case 'f': + case 's': + $pattern = substr_replace($pattern, '', $pos, 1); + $length = strlen($pattern); + $type = 's'; + if ($value === null) { + $value = 'IS NULL'; + $done = true; + } else { + $prefix = '= '; + $type = $next; + } + break; + default: + throw new Exception('Unknown conversion, try %=d, %=s, or %=f.'); + } + break; + + case 'n': // Nullable... + switch ($next) { + case 'd': // ...integer. + case 'f': // ...float. + case 's': // ...string. + $pattern = substr_replace($pattern, '', $pos, 1); + $length = strlen($pattern); + $type = $next; + $nullable = true; + break; + default: + throw new Exception('Unknown conversion, try %nd or %ns.'); + } + break; + + case 'L': // List of.. + _qsprintf_check_type($value, "L{$next}", $pattern); + $pattern = substr_replace($pattern, '', $pos, 1); + $length = strlen($pattern); + $type = 's'; + $done = true; + + switch ($next) { + case 'd': // ...integers. + $value = implode(', ', array_map('intval', $value)); + break; + case 's': // ...strings. + $value = mysql_escape_array_of_strings_for_in_clause($value, $conn); + break; + case 'C': // ...columns. + $value = implode(', ', array_map('mysql_escape_column_name', $value)); + break; + default: + throw new Exception("Unknown conversion %L{$next}."); + } + break; + } + + if (!$done) { + _qsprintf_check_type($value, $type, $pattern); + switch ($type) { + case 's': // String + if ($nullable && $value === null) { + $value = 'NULL'; + } else { + $value = "'".mysql_real_escape_string($value, $conn)."'"; + } + $type = 's'; + break; + + case 'Q': // Query Fragment + $type = 's'; + break; + + case '~': // Like Substring + case '>': // Like Prefix + case '<': // Like Suffix + $value = mysql_real_escape_string($value, $conn); + // Ideally the query shouldn't be modified after safely escaping it, + // but we need to escape _ and % within LIKE terms. + $value = str_replace( + // Even though we've already escaped, we need to replace \ with \\ + // because MYSQL unescapes twice inside a LIKE clause. See note + // at mysql.com. However, if the \ is being used to escape a single + // quote ('), then the \ should not be escaped. Thus, after all \ + // are replaced with \\, we need to revert instances of \\' back to + // \'. + array('\\', '\\\\\'', '_', '%'), + array('\\\\', '\\\'', '\_', '\%'), + $value); + switch ($type) { + case '~': $value = "'%".$value."%'"; break; + case '>': $value = "'" .$value."%'"; break; + case '<': $value = "'%".$value. "'"; break; + } + $type = 's'; + break; + + case 'f': // Float + if ($nullable && $value === null) { + $value = 'NULL'; + } else { + $value = (float)$value; + } + $type = 's'; + break; + + case 'd': // Integer + if ($nullable && $value === null) { + $value = 'NULL'; + } else { + $value = (int)$value; + } + $type = 's'; + break; + + case 'T': // Table + case 'C': // Column + $value = mysql_escape_column_name($value); + $type = 's'; + break; + + case 'K': // Komment + $value = mysql_escape_multiline_comment($value); + $type = 's'; + break; + + default: + throw new Exception("Unknown conversion '%{$type}'."); + + } + } + + if ($prefix) { + $value = $prefix.$value; + } + $pattern[$pos] = $type; +} + +function _qsprintf_check_type($value, $type, $query) { + switch ($type) { + case 'Ld': case 'Ls': case 'LC': case 'LA': case 'LO': + if (!is_array($value)) { + throw new QsprintfQueryParameterException( + $query, + "Expected array argument for %{$type} conversion."); + } + if (empty($value)) { + throw new QsprintfQueryParameterException( + $query, + "Array for %{$type} conversion is empty."); + } + + foreach ($value as $scalar) { + _qsprintf_check_scalar_type($scalar, $type, $query); + } + break; + default: + _qsprintf_check_scalar_type($value, $type, $query); + } +} + + +function _qsprintf_check_scalar_type($value, $type, $query) { + switch ($type) { + case 'Q': case 'LC': case 'T': case 'C': + if (!is_string($value)) { + throw new QsprintfQueryParameterException( + $query, + "Expected a string for %{$type} conversion."); + } + break; + + case 'Ld': case 'd': case 'f': + if (!is_null($value) && !is_scalar($value)) { + throw new QsprintfQueryParameterException( + $query, + "Expected a scalar or null for %{$type} conversion."); + } + break; + + case 'Ls': case 's': + case '~': case '>': case '<': case 'K': + if (!is_null($value) && !is_scalar($value)) { + throw new QsprintfQueryParameterException( + $query, + "Expected a scalar or null for %{$type} conversion."); + } + break; + + case 'LA': case 'LO': + if (!is_null($value) && !is_scalar($value) && + !(is_array($value) && !empty($value))) { + throw new QsprintfQueryParameterException( + $query, + "Expected a scalar or null or non-empty array for ". + "%{$type} conversion."); + } + break; + default: + throw new Exception("Unknown conversion '{$type}'."); + } +} + + +function mysql_escape_column_name($value) { + return '`'.str_replace('`', '\\`', $value).'`'; +} + +function mysql_escape_multiline_comment($comment) { + + // These can either terminate a comment, confuse the hell out of the parser, + // make MySQL execute the comment as a query, or, in the case of semicolon, + // are quasi-dangerous because the semicolon could turn a broken query into + // a working query plus an ignored query. + + static $bad = array( + '--', '*/', '//', '#', '!', ';'); + static $safe = array( + '(DOUBLEDASH)', '(STARSLASH)', '(SLASHSLASH)', '(HASH)', '(BANG)', + '(SEMICOLON)'); + + $comment = str_replace($bad, $safe, $comment); + + // For good measure, kill anything else that isn't a nice printable + // character. + + $comment = preg_replace('/[^\x20-\x7F]+/', ' ', $comment); + + return '/* '.$comment.' */'; +} + +function mysql_escape_array_of_strings_for_in_clause($strings, $conn) { + if (!count($strings)) { + return 'NULL'; + } + + foreach ($strings as &$string) { + if ($string === null) { + $string = 'NULL'; + } else { + $string = "'".mysql_real_escape_string($string, $conn)."'"; + } + } + + return implode(', ', $strings); +} diff --git a/src/xsprintf/xsprintf.php b/src/xsprintf/xsprintf.php new file mode 100644 index 0000000..654429e --- /dev/null +++ b/src/xsprintf/xsprintf.php @@ -0,0 +1,72 @@ += $argc) { + throw new Exception("Too few arguments to xsprintf()."); + } + + $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 Exception("Too many arguments to xsprintf()."); + } + + $argv[0] = $pattern; + + return call_user_func_array('sprintf', $argv); +}