resources = $resources; } public function getNameMap() { return $this->nameMap; } public function getSymbolMap() { return $this->symbolMap; } public function getRequiresMap() { return $this->requiresMap; } public function getPackageMap() { return $this->packageMap; } public function setDebug($debug) { $this->debug = $debug; return $this; } protected function log($message) { if ($this->debug) { $console = PhutilConsole::getConsole(); $console->writeErr("%s\n", $message); } } public function generate() { $binary_map = $this->rebuildBinaryResources($this->resources); $this->log(pht('Found %d binary resources.', count($binary_map))); $xformer = id(new CelerityResourceTransformer()) ->setMinify(false) ->setRawURIMap(ipull($binary_map, 'uri')); $text_map = $this->rebuildTextResources($this->resources, $xformer); $this->log(pht('Found %d text resources.', count($text_map))); $resource_graph = array(); $requires_map = array(); $symbol_map = array(); foreach ($text_map as $name => $info) { if (isset($info['provides'])) { $symbol_map[$info['provides']] = $info['hash']; // We only need to check for cycles and add this to the requires map // if it actually requires anything. if (!empty($info['requires'])) { $resource_graph[$info['provides']] = $info['requires']; $requires_map[$info['hash']] = $info['requires']; } } } $this->detectGraphCycles($resource_graph); $name_map = ipull($binary_map, 'hash') + ipull($text_map, 'hash'); $hash_map = array_flip($name_map); $package_map = $this->rebuildPackages( $this->resources, $symbol_map, $hash_map); $this->log(pht('Found %d packages.', count($package_map))); $component_map = array(); foreach ($package_map as $package_name => $package_info) { foreach ($package_info['symbols'] as $symbol) { $component_map[$symbol] = $package_name; } } $name_map = $this->mergeNameMaps( array( array(pht('Binary'), ipull($binary_map, 'hash')), array(pht('Text'), ipull($text_map, 'hash')), array(pht('Package'), ipull($package_map, 'hash')), )); $package_map = ipull($package_map, 'symbols'); ksort($name_map, SORT_STRING); ksort($symbol_map, SORT_STRING); ksort($requires_map, SORT_STRING); ksort($package_map, SORT_STRING); $this->nameMap = $name_map; $this->symbolMap = $symbol_map; $this->requiresMap = $requires_map; $this->packageMap = $package_map; return $this; } public function write() { $map_content = $this->formatMapContent(array( 'names' => $this->getNameMap(), 'symbols' => $this->getSymbolMap(), 'requires' => $this->getRequiresMap(), 'packages' => $this->getPackageMap(), )); $map_path = $this->resources->getPathToMap(); $this->log(pht('Writing map "%s".', Filesystem::readablePath($map_path))); Filesystem::writeFile($map_path, $map_content); return $this; } private function formatMapContent(array $data) { $content = phutil_var_export($data); $generated = '@'.'generated'; return <<> Resource information map. */ private function rebuildBinaryResources( CelerityPhysicalResources $resources) { $binary_map = $resources->findBinaryResources(); $result_map = array(); foreach ($binary_map as $name => $data_hash) { $hash = $resources->getCelerityHash($data_hash.$name); $result_map[$name] = array( 'hash' => $hash, 'uri' => $resources->getResourceURI($hash, $name), ); } return $result_map; } /** * Find text resources (like JS and CSS) and return information about them. * * @param CelerityPhysicalResources Resource map to find text resources for. * @param CelerityResourceTransformer Configured resource transformer. * @return map> Resource information map. */ private function rebuildTextResources( CelerityPhysicalResources $resources, CelerityResourceTransformer $xformer) { $text_map = $resources->findTextResources(); $result_map = array(); foreach ($text_map as $name => $data_hash) { $raw_data = $resources->getResourceData($name); $xformed_data = $xformer->transformResource($name, $raw_data); $data_hash = $resources->getCelerityHash($xformed_data); $hash = $resources->getCelerityHash($data_hash.$name); list($provides, $requires) = $this->getProvidesAndRequires( $name, $raw_data); $result_map[$name] = array( 'hash' => $hash, ); if ($provides !== null) { $result_map[$name] += array( 'provides' => $provides, 'requires' => $requires, ); } } return $result_map; } /** * Parse the `@provides` and `@requires` symbols out of a text resource, like * JS or CSS. * * @param string Resource name. * @param string Resource data. * @return pair|null> The `@provides` symbol and * the list of `@requires` symbols. If the resource is not part of the * dependency graph, both are null. */ private function getProvidesAndRequires($name, $data) { $parser = new PhutilDocblockParser(); $matches = array(); $ok = preg_match('@/[*][*].*?[*]/@s', $data, $matches); if (!$ok) { throw new Exception( pht( 'Resource "%s" does not have a header doc comment. Encode '. 'dependency data in a header docblock.', $name)); } list($description, $metadata) = $parser->parse($matches[0]); $provides = $this->parseResourceSymbolList(idx($metadata, 'provides')); $requires = $this->parseResourceSymbolList(idx($metadata, 'requires')); if (!$provides) { // Tests and documentation-only JS is permitted to @provide no targets. return array(null, null); } if (count($provides) > 1) { throw new Exception( pht( 'Resource "%s" must %s at most one Celerity target.', $name, '@provide')); } return array(head($provides), $requires); } /** * Check for dependency cycles in the resource graph. Raises an exception if * a cycle is detected. * * @param map> Map of `@provides` symbols to their * `@requires` symbols. * @return void */ private function detectGraphCycles(array $nodes) { $graph = id(new CelerityResourceGraph()) ->addNodes($nodes) ->setResourceGraph($nodes) ->loadGraph(); foreach ($nodes as $provides => $requires) { $cycle = $graph->detectCycles($provides); if ($cycle) { throw new Exception( pht( 'Cycle detected in resource graph: %s', implode(' > ', $cycle))); } } } /** * Build package specifications for a given resource source. * * @param CelerityPhysicalResources Resource source to rebuild. * @param map Map of `@provides` to hashes. * @param map Map of hashes to resource names. * @return map> Package information maps. */ private function rebuildPackages( CelerityPhysicalResources $resources, array $symbol_map, array $reverse_map) { $package_map = array(); $package_spec = $resources->getResourcePackages(); foreach ($package_spec as $package_name => $package_symbols) { $type = null; $hashes = array(); foreach ($package_symbols as $symbol) { $symbol_hash = idx($symbol_map, $symbol); if ($symbol_hash === null) { throw new Exception( pht( 'Package specification for "%s" includes "%s", but that symbol '. 'is not %s by any resource.', $package_name, $symbol, '@provided')); } $resource_name = $reverse_map[$symbol_hash]; $resource_type = $resources->getResourceType($resource_name); if ($type === null) { $type = $resource_type; } else if ($type !== $resource_type) { throw new Exception( pht( 'Package specification for "%s" includes resources of multiple '. 'types (%s, %s). Each package may only contain one type of '. 'resource.', $package_name, $type, $resource_type)); } $hashes[] = $symbol.':'.$symbol_hash; } $hash = $resources->getCelerityHash(implode("\n", $hashes)); $package_map[$package_name] = array( 'hash' => $hash, 'symbols' => $package_symbols, ); } return $package_map; } private function mergeNameMaps(array $maps) { $result = array(); $origin = array(); foreach ($maps as $map) { list($map_name, $data) = $map; foreach ($data as $name => $hash) { if (empty($result[$name])) { $result[$name] = $hash; $origin[$name] = $map_name; } else { $old = $origin[$name]; $new = $map_name; throw new Exception( pht( 'Resource source defines two resources with the same name, '. '"%s". One is defined in the "%s" map; the other in the "%s" '. 'map. Each resource must have a unique name.', $name, $old, $new)); } } } return $result; } private function parseResourceSymbolList($list) { if (!$list) { return array(); } // This is valid: // // @requires x y // // But so is this: // // @requires x // @requires y // // Accept either form and produce a list of symbols. $list = (array)$list; // We can get `true` values if there was a bare `@requires` in the input. foreach ($list as $key => $item) { if ($item === true) { unset($list[$key]); } } $list = implode(' ', $list); $list = trim($list); $list = preg_split('/\s+/', $list); $list = array_filter($list); return $list; } }