diff --git a/src/symbols/PhutilClassMapQuery.php b/src/symbols/PhutilClassMapQuery.php index f0347cef..531e85c2 100644 --- a/src/symbols/PhutilClassMapQuery.php +++ b/src/symbols/PhutilClassMapQuery.php @@ -1,318 +1,324 @@ ancestorClass = $class; return $this; } /** * Provide a method to select a unique key for each instance. * * If you provide a method here, the map will be keyed with these values, * instead of with class names. Exceptions will be raised if entries are * not unique. * * You must provide a method here to use @{method:setExpandMethod}. * * @param string Name of the unique key method. * @param bool If true, then classes which return `null` will be filtered * from the results. * @return this * @task config */ public function setUniqueMethod($unique_method, $filter_null = false) { $this->uniqueMethod = $unique_method; $this->filterNull = $filter_null; return $this; } /** * Provide a method to expand each concrete subclass into available instances. * * With some class maps, each class is allowed to provide multiple entries * in the map by returning alternatives from some method with a default * implementation like this: * * public function generateVariants() { * return array($this); * } * * For example, a "color" class may really generate and configure several * instances in the final class map: * * public function generateVariants() { * return array( * self::newColor('red'), * self::newColor('green'), * self::newColor('blue'), * ); * } * * This allows multiple entires in the final map to share an entire * implementation, rather than requiring that they each have their own unique * subclass. * * This pattern is most useful if several variants are nearly identical (so * the stub subclasses would be essentially empty) or the available variants * are driven by configuration. * * If a class map uses this pattern, it must also provide a unique key for * each instance with @{method:setUniqueMethod}. * * @param string Name of the expansion method. * @return this * @task config */ public function setExpandMethod($expand_method) { $this->expandMethod = $expand_method; return $this; } /** * Provide a method to sort the final map. * * The map will be sorted using @{function:msort} and passing this method * name. * * @param string Name of the sorting method. * @return this * @task config */ public function setSortMethod($sort_method) { $this->sortMethod = $sort_method; return $this; } /** * Provide a method to filter the map. * * @param string Name of the filtering method. * @return this * @task config */ public function setFilterMethod($filter_method) { $this->filterMethod = $filter_method; return $this; } + public function setContinueOnFailure($continue) { + $this->continueOnFailure = $continue; + return $this; + } /* -( Executing the Query )------------------------------------------------ */ /** * Execute the query as configured. * * @return map Realized class map. * @task exec */ public function execute() { $cache_key = $this->getCacheKey(); if (!isset(self::$cache[$cache_key])) { self::$cache[$cache_key] = $this->loadMap(); } return self::$cache[$cache_key]; } /** * Delete all class map caches. * * @return void * @task exec */ public static function deleteCaches() { self::$cache = array(); } /** * Generate the core query results. * * This method is used to fill the cache. * * @return map Realized class map. * @task exec */ private function loadMap() { $ancestor = $this->ancestorClass; if (!strlen($ancestor)) { throw new PhutilInvalidStateException('setAncestorClass'); } if (!class_exists($ancestor) && !interface_exists($ancestor)) { throw new Exception( pht( 'Trying to execute a class map query for descendants of class '. '"%s", but no such class or interface exists.', $ancestor)); } $expand = $this->expandMethod; $filter = $this->filterMethod; $unique = $this->uniqueMethod; $sort = $this->sortMethod; if (strlen($expand)) { if (!strlen($unique)) { throw new Exception( pht( 'Trying to execute a class map query for descendants of class '. '"%s", but the query specifies an "expand method" ("%s") without '. 'specifying a "unique method". Class maps which support expansion '. 'must have unique keys.', $ancestor, $expand)); } } $objects = id(new PhutilSymbolLoader()) ->setAncestorClass($ancestor) + ->setContinueOnFailure($this->continueOnFailure) ->loadObjects(); // Apply the "expand" mechanism, if it is configured. if (strlen($expand)) { $list = array(); foreach ($objects as $object) { foreach (call_user_func(array($object, $expand)) as $instance) { $list[] = $instance; } } } else { $list = $objects; } // Apply the "unique" mechanism, if it is configured. if (strlen($unique)) { $map = array(); foreach ($list as $object) { $key = call_user_func(array($object, $unique)); if ($key === null && $this->filterNull) { continue; } if (empty($map[$key])) { $map[$key] = $object; continue; } throw new Exception( pht( 'Two objects (of classes "%s" and "%s", descendants of ancestor '. 'class "%s") returned the same key from "%s" ("%s"), but each '. 'object in this class map must be identified by a unique key.', get_class($object), get_class($map[$key]), $ancestor, $unique.'()', $key)); } } else { $map = $list; } // Apply the "filter" mechanism, if it is configured. if (strlen($filter)) { $map = mfilter($map, $filter); } // Apply the "sort" mechanism, if it is configured. if (strlen($sort)) { $map = msort($map, $sort); } return $map; } /* -( Managing the Map Cache )--------------------------------------------- */ /** * Return a cache key for this query. * * @return string Cache key. * @task cache */ public function getCacheKey() { $parts = array( $this->ancestorClass, $this->uniqueMethod, $this->filterNull, $this->expandMethod, $this->filterMethod, $this->sortMethod, ); return implode(':', $parts); } } diff --git a/src/symbols/PhutilSymbolLoader.php b/src/symbols/PhutilSymbolLoader.php index 1c8fccf9..4c0c99f0 100644 --- a/src/symbols/PhutilSymbolLoader.php +++ b/src/symbols/PhutilSymbolLoader.php @@ -1,414 +1,457 @@ setType('class') * ->setLibrary('example') * ->selectAndLoadSymbols(); * ``` * * When you execute the loading query, it returns a dictionary of matching * symbols: * * ```lang=php * array( * 'class$Example' => array( * 'type' => 'class', * 'name' => 'Example', * 'library' => 'libexample', * 'where' => 'examples/example.php', * ), * // ... more ... * ); * ``` * * The **library** and **where** keys show where the symbol is defined. The * **type** and **name** keys identify the symbol itself. * * NOTE: This class must not use libphutil functions, including @{function:id} * and @{function:idx}. * * @task config Configuring the Query * @task load Loading Symbols * @task internal Internals */ final class PhutilSymbolLoader { private $type; private $library; private $base; private $name; private $concrete; private $pathPrefix; private $suppressLoad; + private $continueOnFailure; /** * Select the type of symbol to load, either `class`, `function` or * `interface`. * * @param string Type of symbol to load. * @return this * * @task config */ public function setType($type) { $this->type = $type; return $this; } /** * Restrict the symbol query to a specific library; only symbols from this * library will be loaded. * * @param string Library name. * @return this * * @task config */ public function setLibrary($library) { // Validate the library name; this throws if the library in not loaded. $bootloader = PhutilBootloader::getInstance(); $bootloader->getLibraryRoot($library); $this->library = $library; return $this; } /** * Restrict the symbol query to a specific path prefix; only symbols defined * in files below that path will be selected. * * @param string Path relative to library root, like "apps/cheese/". * @return this * * @task config */ public function setPathPrefix($path) { $this->pathPrefix = str_replace(DIRECTORY_SEPARATOR, '/', $path); return $this; } /** * Restrict the symbol query to a single symbol name, e.g. a specific class * or function name. * * @param string Symbol name. * @return this * * @task config */ public function setName($name) { $this->name = $name; return $this; } /** * Restrict the symbol query to only descendants of some class. This will * strictly select descendants, the base class will not be selected. This * implies loading only classes. * * @param string Base class name. * @return this * * @task config */ public function setAncestorClass($base) { $this->base = $base; return $this; } /** * Restrict the symbol query to only concrete symbols; this will filter out * abstract classes. * * NOTE: This currently causes class symbols to load, even if you run * @{method:selectSymbolsWithoutLoading}. * * @param bool True if the query should load only concrete symbols. * @return this * * @task config */ public function setConcreteOnly($concrete) { $this->concrete = $concrete; return $this; } + public function setContinueOnFailure($continue) { + $this->continueOnFailure = $continue; + return $this; + } + /* -( Load )--------------------------------------------------------------- */ /** * Execute the query and select matching symbols, then load them so they can * be used. * * @return dict A dictionary of matching symbols. See top-level class * documentation for details. These symbols will be loaded * and available. * * @task load */ public function selectAndLoadSymbols() { $map = array(); $bootloader = PhutilBootloader::getInstance(); if ($this->library) { $libraries = array($this->library); } else { $libraries = $bootloader->getAllLibraries(); } if ($this->type) { $types = array($this->type); } else { $types = array( 'class', 'function', ); } $names = null; if ($this->base) { $names = $this->selectDescendantsOf( $bootloader->getClassTree(), $this->base); } $symbols = array(); foreach ($libraries as $library) { $map = $bootloader->getLibraryMap($library); foreach ($types as $type) { if ($type == 'interface') { $lookup_map = $map['class']; } else { $lookup_map = $map[$type]; } // As an optimization, we filter the list of candidate symbols in // several passes, applying a name-based filter first if possible since // it is highly selective and guaranteed to match at most one symbol. // This is the common case and we land here through `__autoload()` so // it's worthwhile to micro-optimize a bit because this code path is // very hot and we save 5-10ms per page for a very moderate increase in // complexity. if ($this->name) { // If we have a name filter, just pick the matching name out if it // exists. if (isset($lookup_map[$this->name])) { $filtered_map = array( $this->name => $lookup_map[$this->name], ); } else { $filtered_map = array(); } } else if ($names !== null) { $filtered_map = array(); foreach ($names as $name => $ignored) { if (isset($lookup_map[$name])) { $filtered_map[$name] = $lookup_map[$name]; } } } else { // Otherwise, start with everything. $filtered_map = $lookup_map; } if ($this->pathPrefix) { $len = strlen($this->pathPrefix); foreach ($filtered_map as $name => $where) { if (strncmp($where, $this->pathPrefix, $len) !== 0) { unset($filtered_map[$name]); } } } foreach ($filtered_map as $name => $where) { $symbols[$type.'$'.$name] = array( 'type' => $type, 'name' => $name, 'library' => $library, 'where' => $where, ); } } } if (!$this->suppressLoad) { + + // Loading a class may trigger the autoloader to load more classes + // (usually, the parent class), so we need to keep track of whether we + // are currently loading in "continue on failure" mode. Otherwise, we'll + // fail anyway if we fail to load a parent class. + + // The driving use case for the "continue on failure" mode is to let + // "arc liberate" run so it can rebuild the library map, even if you have + // made changes to Workflow or Config classes which it must load before + // it can operate. If we don't let it continue on failure, it is very + // difficult to remove or move Workflows. + + static $continue_depth = 0; + if ($this->continueOnFailure) { + $continue_depth++; + } + $caught = null; - foreach ($symbols as $symbol) { + foreach ($symbols as $key => $symbol) { try { $this->loadSymbol($symbol); } catch (Exception $ex) { + // If we failed to load this symbol, remove it from the results. + // Otherwise, we may fatal below when trying to reflect it. + unset($symbols[$key]); + $caught = $ex; } } + + $should_continue = ($continue_depth > 0); + + if ($this->continueOnFailure) { + $continue_depth--; + } + if ($caught) { // NOTE: We try to load everything even if we fail to load something, // primarily to make it possible to remove functions from a libphutil // library without breaking library startup. - throw $caught; + if ($should_continue) { + // We may not have `pht()` yet. + fprintf( + STDERR, + "%s: %s\n", + 'IGNORING CLASS LOAD FAILURE', + $caught->getMessage()); + } else { + throw $caught; + } } } if ($this->concrete) { // Remove 'abstract' classes. foreach ($symbols as $key => $symbol) { if ($symbol['type'] == 'class') { $reflection = new ReflectionClass($symbol['name']); if ($reflection->isAbstract()) { unset($symbols[$key]); } } } } return $symbols; } /** * Execute the query and select matching symbols, but do not load them. This * will perform slightly better if you are only interested in the existence * of the symbols and don't plan to use them; otherwise, use * @{method:selectAndLoadSymbols}. * * @return dict A dictionary of matching symbols. See top-level class * documentation for details. * * @task load */ public function selectSymbolsWithoutLoading() { $this->suppressLoad = true; $result = $this->selectAndLoadSymbols(); $this->suppressLoad = false; return $result; } /** * Select symbols matching the query and then instantiate them, returning * concrete objects. This is a convenience method which simplifies symbol * handling if you are only interested in building objects. * * If you want to do more than build objects, or want to build objects with * varying constructor arguments, use @{method:selectAndLoadSymbols} for * fine-grained control over results. * * This method implicitly restricts the query to match only concrete * classes. * * @param list List of constructor arguments. * @return map Map of class names to constructed objects. */ public function loadObjects(array $argv = array()) { $symbols = $this ->setConcreteOnly(true) ->setType('class') ->selectAndLoadSymbols(); $objects = array(); foreach ($symbols as $symbol) { $objects[$symbol['name']] = newv($symbol['name'], $argv); } return $objects; } /* -( Internals )---------------------------------------------------------- */ /** * @task internal */ private function selectDescendantsOf(array $tree, $root) { $result = array(); if (empty($tree[$root])) { // No known descendants. return array(); } foreach ($tree[$root] as $child) { $result[$child] = true; if (!empty($tree[$child])) { $result += $this->selectDescendantsOf($tree, $child); } } return $result; } /** * @task internal */ private function loadSymbol(array $symbol_spec) { // Check if we've already loaded the symbol; bail if we have. $name = $symbol_spec['name']; $is_function = ($symbol_spec['type'] == 'function'); if ($is_function) { if (function_exists($name)) { return; } } else { if (class_exists($name, false) || interface_exists($name, false)) { return; } } $lib_name = $symbol_spec['library']; $where = $symbol_spec['where']; $bootloader = PhutilBootloader::getInstance(); $bootloader->loadLibrarySource($lib_name, $where); // Check that we successfully loaded the symbol from wherever it was // supposed to be defined. $load_failed = null; if ($is_function) { if (!function_exists($name)) { $load_failed = pht('function'); } } else { if (!class_exists($name, false) && !interface_exists($name, false)) { $load_failed = pht('class or interface'); } } if ($load_failed !== null) { $lib_path = phutil_get_library_root($lib_name); throw new PhutilMissingSymbolException( $name, $load_failed, pht( "the symbol map for library '%s' (at '%s') claims this %s is ". "defined in '%s', but loading that source file did not cause the ". "%s to become defined.", $lib_name, $lib_path, $load_failed, $where, $load_failed)); } } }