diff --git a/src/configuration/ArcanistConfiguration.php b/src/configuration/ArcanistConfiguration.php index 71152587..91d0dc65 100644 --- a/src/configuration/ArcanistConfiguration.php +++ b/src/configuration/ArcanistConfiguration.php @@ -1,272 +1,254 @@ buildAllWorkflows(), $command); } public function buildAllWorkflows() { - $workflows_by_name = array(); - - $workflows_by_class_name = id(new PhutilSymbolLoader()) + return id(new PhutilClassMapQuery()) ->setAncestorClass('ArcanistWorkflow') - ->loadObjects(); - foreach ($workflows_by_class_name as $class => $workflow) { - $name = $workflow->getWorkflowName(); - - if (isset($workflows_by_name[$name])) { - $other = get_class($workflows_by_name[$name]); - throw new Exception( - pht( - 'Workflows %s and %s both implement workflows named %s.', - $class, - $other, - $name)); - } - - $workflows_by_name[$name] = $workflow; - } - - return $workflows_by_name; + ->setUniqueMethod('getWorkflowName') + ->execute(); } final public function isValidWorkflow($workflow) { return (bool)$this->buildWorkflow($workflow); } public function willRunWorkflow($command, ArcanistWorkflow $workflow) { // This is a hook. } public function didRunWorkflow($command, ArcanistWorkflow $workflow, $err) { // This is a hook. } public function didAbortWorkflow($command, $workflow, Exception $ex) { // This is a hook. } public function getCustomArgumentsForCommand($command) { return array(); } final public function selectWorkflow( &$command, array &$args, ArcanistConfigurationManager $configuration_manager, PhutilConsole $console) { // First, try to build a workflow with the exact name provided. We always // pick an exact match, and do not allow aliases to override it. $workflow = $this->buildWorkflow($command); if ($workflow) { return $workflow; } // If the user has an alias, like 'arc alias dhelp diff help', look it up // and substitute it. We do this only after trying to resolve the workflow // normally to prevent you from doing silly things like aliasing 'alias' // to something else. $aliases = ArcanistAliasWorkflow::getAliases($configuration_manager); list($new_command, $args) = ArcanistAliasWorkflow::resolveAliases( $command, $this, $args, $configuration_manager); $full_alias = idx($aliases, $command, array()); $full_alias = implode(' ', $full_alias); // Run shell command aliases. if (ArcanistAliasWorkflow::isShellCommandAlias($new_command)) { $shell_cmd = substr($full_alias, 1); $console->writeLog( "[%s: 'arc %s' -> $ %s]", pht('alias'), $command, $shell_cmd); if ($args) { $err = phutil_passthru('%C %Ls', $shell_cmd, $args); } else { $err = phutil_passthru('%C', $shell_cmd); } exit($err); } // Run arc command aliases. if ($new_command) { $workflow = $this->buildWorkflow($new_command); if ($workflow) { $console->writeLog( "[%s: 'arc %s' -> 'arc %s']\n", pht('alias'), $command, $full_alias); $command = $new_command; return $workflow; } } $all = array_keys($this->buildAllWorkflows()); // We haven't found a real command or an alias, so try to locate a command // by unique prefix. $prefixes = $this->expandCommandPrefix($command, $all); if (count($prefixes) == 1) { $command = head($prefixes); return $this->buildWorkflow($command); } else if (count($prefixes) > 1) { $this->raiseUnknownCommand($command, $prefixes); } // We haven't found a real command, alias, or unique prefix. Try similar // spellings. $corrected = self::correctCommandSpelling($command, $all, 2); if (count($corrected) == 1) { $console->writeErr( pht( "(Assuming '%s' is the British spelling of '%s'.)", $command, head($corrected))."\n"); $command = head($corrected); return $this->buildWorkflow($command); } else if (count($corrected) > 1) { $this->raiseUnknownCommand($command, $corrected); } $this->raiseUnknownCommand($command); } private function raiseUnknownCommand($command, array $maybe = array()) { $message = pht("Unknown command '%s'. Try '%s'.", $command, 'arc help'); if ($maybe) { $message .= "\n\n".pht('Did you mean:')."\n"; sort($maybe); foreach ($maybe as $other) { $message .= " ".$other."\n"; } } throw new ArcanistUsageException($message); } private function expandCommandPrefix($command, array $options) { $is_prefix = array(); foreach ($options as $option) { if (strncmp($option, $command, strlen($command)) == 0) { $is_prefix[$option] = true; } } return array_keys($is_prefix); } public static function correctCommandSpelling( $command, array $options, $max_distance) { // Adjust to the scaled edit costs we use below, so "2" roughly means // "2 edits". $max_distance = $max_distance * 3; // These costs are somewhat made up, but the theory is that it is far more // likely you will mis-strike a key ("lans" for "land") or press two keys // out of order ("alnd" for "land") than omit keys or press extra keys. $matrix = id(new PhutilEditDistanceMatrix()) ->setInsertCost(4) ->setDeleteCost(4) ->setReplaceCost(3) ->setTransposeCost(2); return self::correctSpelling($command, $options, $matrix, $max_distance); } public static function correctArgumentSpelling($command, array $options) { $max_distance = 1; // We are stricter with arguments - we allow only one inserted or deleted // character. It is mainly to handle cases like --no-lint versus --nolint // or --reviewer versus --reviewers. $matrix = id(new PhutilEditDistanceMatrix()) ->setInsertCost(1) ->setDeleteCost(1) ->setReplaceCost(10); return self::correctSpelling($command, $options, $matrix, $max_distance); } public static function correctSpelling( $input, array $options, PhutilEditDistanceMatrix $matrix, $max_distance) { $distances = array(); $inputv = str_split($input); foreach ($options as $option) { $optionv = str_split($option); $matrix->setSequences($optionv, $inputv); $distances[$option] = $matrix->getEditDistance(); } asort($distances); $best = min($max_distance, reset($distances)); foreach ($distances as $option => $distance) { if ($distance > $best) { unset($distances[$option]); } } // Before filtering, check if we have multiple equidistant matches and // return them if we do. This prevents us from, e.g., matching "alnd" with // both "land" and "amend", then dropping "land" for being too short, and // incorrectly completing to "amend". if (count($distances) > 1) { return array_keys($distances); } foreach ($distances as $option => $distance) { if (strlen($option) < $distance) { unset($distances[$option]); } } return array_keys($distances); } } diff --git a/src/lint/engine/ArcanistConfigurationDrivenLintEngine.php b/src/lint/engine/ArcanistConfigurationDrivenLintEngine.php index 531ccf6c..9d8693af 100644 --- a/src/lint/engine/ArcanistConfigurationDrivenLintEngine.php +++ b/src/lint/engine/ArcanistConfigurationDrivenLintEngine.php @@ -1,220 +1,194 @@ getWorkingCopy(); $config_path = $working_copy->getProjectPath('.arclint'); if (!Filesystem::pathExists($config_path)) { throw new ArcanistUsageException( pht( "Unable to find '%s' file to configure linters. Create an ". "'%s' file in the root directory of the working copy.", '.arclint', '.arclint')); } $data = Filesystem::readFile($config_path); $config = null; try { $config = phutil_json_decode($data); } catch (PhutilJSONParserException $ex) { throw new PhutilProxyException( pht( "Expected '%s' file to be a valid JSON file, but ". "failed to decode '%s'.", '.arclint', $config_path), $ex); } $linters = $this->loadAvailableLinters(); try { PhutilTypeSpec::checkMap( $config, array( 'exclude' => 'optional regex | list', 'linters' => 'map>', )); } catch (PhutilTypeCheckException $ex) { throw new PhutilProxyException( pht("Error in parsing '%s' file.", $config_path), $ex); } $global_exclude = (array)idx($config, 'exclude', array()); $built_linters = array(); $all_paths = $this->getPaths(); foreach ($config['linters'] as $name => $spec) { $type = idx($spec, 'type'); if ($type !== null) { if (empty($linters[$type])) { throw new ArcanistUsageException( pht( "Linter '%s' specifies invalid type '%s'. ". "Available linters are: %s.", $name, $type, implode(', ', array_keys($linters)))); } $linter = clone $linters[$type]; $linter->setEngine($this); $more = $linter->getLinterConfigurationOptions(); foreach ($more as $key => $option_spec) { PhutilTypeSpec::checkMap( $option_spec, array( 'type' => 'string', 'help' => 'string', )); $more[$key] = $option_spec['type']; } } else { // We'll raise an error below about the invalid "type" key. $linter = null; $more = array(); } try { PhutilTypeSpec::checkMap( $spec, array( 'type' => 'string', 'include' => 'optional regex | list', 'exclude' => 'optional regex | list', ) + $more); } catch (PhutilTypeCheckException $ex) { throw new PhutilProxyException( pht( "Error in parsing '%s' file, for linter '%s'.", '.arclint', $name), $ex); } foreach ($more as $key => $value) { if (array_key_exists($key, $spec)) { try { $linter->setLinterConfigurationValue($key, $spec[$key]); } catch (Exception $ex) { throw new PhutilProxyException( pht( "Error in parsing '%s' file, in key '%s' for linter '%s'.", '.arclint', $key, $name), $ex); } } } $include = (array)idx($spec, 'include', array()); $exclude = (array)idx($spec, 'exclude', array()); $console = PhutilConsole::getConsole(); $console->writeLog( "%s\n", pht("Examining paths for linter '%s'.", $name)); $paths = $this->matchPaths( $all_paths, $include, $exclude, $global_exclude); $console->writeLog( "%s\n", pht("Found %d matching paths for linter '%s'.", count($paths), $name)); $linter->setPaths($paths); $built_linters[] = $linter; } return $built_linters; } private function loadAvailableLinters() { - $linters = id(new PhutilSymbolLoader()) + return id(new PhutilClassMapQuery()) ->setAncestorClass('ArcanistLinter') - ->loadObjects(); - - $map = array(); - foreach ($linters as $linter) { - $name = $linter->getLinterConfigurationName(); - - // This linter isn't selectable through configuration. - if ($name === null) { - continue; - } - - if (empty($map[$name])) { - $map[$name] = $linter; - continue; - } - - $orig_class = get_class($map[$name]); - $this_class = get_class($linter); - throw new Exception( - pht( - "Two linters (`%s`, `%s`) both have the same configuration ". - "name ('%s'). Linters must have unique configuration names.", - $orig_class, - $this_class, - $name)); - } - - return $map; + ->setUniqueMethod('getLinterConfigurationName', true) + ->execute(); } private function matchPaths( array $paths, array $include, array $exclude, array $global_exclude) { $console = PhutilConsole::getConsole(); $match = array(); foreach ($paths as $path) { $keep = false; if (!$include) { $keep = true; } else { foreach ($include as $rule) { if (preg_match($rule, $path)) { $keep = true; break; } } } if (!$keep) { continue; } if ($exclude) { foreach ($exclude as $rule) { if (preg_match($rule, $path)) { continue 2; } } } if ($global_exclude) { foreach ($global_exclude as $rule) { if (preg_match($rule, $path)) { continue 2; } } } $match[] = $path; } return $match; } } diff --git a/src/unit/engine/PhutilUnitTestEngine.php b/src/unit/engine/PhutilUnitTestEngine.php index 7509b300..1dd47220 100644 --- a/src/unit/engine/PhutilUnitTestEngine.php +++ b/src/unit/engine/PhutilUnitTestEngine.php @@ -1,224 +1,222 @@ getRunAllTests()) { $run_tests = $this->getAllTests(); } else { $run_tests = $this->getTestsForPaths(); } if (!$run_tests) { throw new ArcanistNoEffectException(pht('No tests to run.')); } $enable_coverage = $this->getEnableCoverage(); if ($enable_coverage !== false) { if (!function_exists('xdebug_start_code_coverage')) { if ($enable_coverage === true) { throw new ArcanistUsageException( pht( 'You specified %s but %s is not available, so '. 'coverage can not be enabled for %s.', '--coverage', 'XDebug', __CLASS__)); } } else { $enable_coverage = true; } } $test_cases = array(); foreach ($run_tests as $test_class) { $test_case = newv($test_class, array()) ->setEnableCoverage($enable_coverage) ->setWorkingCopy($this->getWorkingCopy()); if ($this->getPaths()) { $test_case->setPaths($this->getPaths()); } if ($this->renderer) { $test_case->setRenderer($this->renderer); } $test_cases[] = $test_case; } foreach ($test_cases as $test_case) { $test_case->willRunTestCases($test_cases); } $results = array(); foreach ($test_cases as $test_case) { $results[] = $test_case->run(); } $results = array_mergev($results); foreach ($test_cases as $test_case) { $test_case->didRunTestCases($test_cases); } return $results; } private function getAllTests() { $project_root = $this->getWorkingCopy()->getProjectRoot(); - $symbols = id(new PhutilSymbolLoader()) - ->setType('class') + $symbols = id(new PhutilClassMapQuery()) ->setAncestorClass('PhutilTestCase') - ->setConcreteOnly(true) - ->selectSymbolsWithoutLoading(); + ->execute(); $in_working_copy = array(); $run_tests = array(); foreach ($symbols as $symbol) { if (!preg_match('@(?:^|/)__tests__/@', $symbol['where'])) { continue; } $library = $symbol['library']; if (!isset($in_working_copy[$library])) { $library_root = phutil_get_library_root($library); $in_working_copy[$library] = Filesystem::isDescendant( $library_root, $project_root); } if ($in_working_copy[$library]) { $run_tests[] = $symbol['name']; } } return $run_tests; } /** * Retrieve all relevant test cases. * * Looks for any class that extends @{class:PhutilTestCase} inside a * `__tests__` directory in any parent directory of every affected file. * * The idea is that "infrastructure/__tests__/" tests defines general tests * for all of "infrastructure/", and those tests run for any change in * "infrastructure/". However, "infrastructure/concrete/rebar/__tests__/" * defines more specific tests that run only when "rebar/" (or some * subdirectory) changes. * * @return list The names of the test case classes to be executed. */ private function getTestsForPaths() { $look_here = $this->getTestPaths(); $run_tests = array(); foreach ($look_here as $path_info) { $library = $path_info['library']; $path = $path_info['path']; $symbols = id(new PhutilSymbolLoader()) ->setType('class') ->setLibrary($library) ->setPathPrefix($path) ->setAncestorClass('PhutilTestCase') ->setConcreteOnly(true) ->selectAndLoadSymbols(); foreach ($symbols as $symbol) { $run_tests[$symbol['name']] = true; } } return array_keys($run_tests); } /** * Returns the paths in which we should look for tests to execute. * * @return list A list of paths in which to search for test cases. */ public function getTestPaths() { $root = $this->getWorkingCopy()->getProjectRoot(); $paths = array(); foreach ($this->getPaths() as $path) { $library_root = phutil_get_library_root_for_path($path); if (!$library_root) { continue; } $library_name = phutil_get_library_name_for_root($library_root); if (!$library_name) { throw new Exception( pht( "Attempting to run unit tests on a libphutil library which has ". "not been loaded, at:\n\n". " %s\n\n". "This probably means one of two things:\n\n". " - You may need to add this library to %s.\n". " - You may be running tests on a copy of libphutil or ". "arcanist using a different copy of libphutil or arcanist. ". "This operation is not supported.\n", $library_root, '.arcconfig.')); } $path = Filesystem::resolvePath($path, $root); $library_path = Filesystem::readablePath($path, $library_root); if (!Filesystem::isDescendant($path, $library_root)) { // We have encountered some kind of symlink maze -- for instance, $path // is some symlink living outside the library that links into some file // inside the library. Just ignore these cases, since the affected file // does not actually lie within the library. continue; } if (is_file($path) && preg_match('@(?:^|/)__tests__/@', $path)) { $paths[$library_name.':'.$library_path] = array( 'library' => $library_name, 'path' => $library_path, ); continue; } foreach (Filesystem::walkToRoot($path, $library_root) as $subpath) { if ($subpath == $library_root) { $paths[$library_name.':.'] = array( 'library' => $library_name, 'path' => '__tests__/', ); } else { $library_subpath = Filesystem::readablePath($subpath, $library_root); $paths[$library_name.':'.$library_subpath] = array( 'library' => $library_name, 'path' => $library_subpath.'/__tests__/', ); } } } return $paths; } public function shouldEchoTestResults() { return !$this->renderer; } } diff --git a/src/workflow/ArcanistLintersWorkflow.php b/src/workflow/ArcanistLintersWorkflow.php index b872e300..6d70b4d6 100644 --- a/src/workflow/ArcanistLintersWorkflow.php +++ b/src/workflow/ArcanistLintersWorkflow.php @@ -1,215 +1,215 @@ array( 'help' => pht('Show detailed information, including options.'), ), ); } public function run() { $console = PhutilConsole::getConsole(); - $linters = id(new PhutilSymbolLoader()) + $linters = id(new PhutilClassMapQuery()) ->setAncestorClass('ArcanistLinter') - ->loadObjects(); + ->execute(); try { $built = $this->newLintEngine()->buildLinters(); } catch (ArcanistNoEngineException $ex) { $built = array(); } // Note that an engine can emit multiple linters of the same class to run // different rulesets on different groups of files, so these linters do not // necessarily have unique classes or types. $groups = array(); foreach ($built as $linter) { $groups[get_class($linter)][] = $linter; } $linter_info = array(); foreach ($linters as $key => $linter) { $installed = idx($groups, $key, array()); $exception = null; if ($installed) { $status = 'configured'; try { $version = head($installed)->getVersion(); } catch (Exception $ex) { $status = 'error'; $exception = $ex; } } else { $status = 'available'; $version = null; } $linter_info[$key] = array( 'short' => $linter->getLinterConfigurationName(), 'class' => get_class($linter), 'status' => $status, 'version' => $version, 'name' => $linter->getInfoName(), 'uri' => $linter->getInfoURI(), 'description' => $linter->getInfoDescription(), 'exception' => $exception, 'options' => $linter->getLinterConfigurationOptions(), ); } $linter_info = isort($linter_info, 'short'); $status_map = $this->getStatusMap(); $pad = ' '; $color_map = array( 'configured' => 'green', 'available' => 'yellow', 'error' => 'red', ); foreach ($linter_info as $key => $linter) { $status = $linter['status']; $color = $color_map[$status]; $text = $status_map[$status]; $print_tail = false; $console->writeOut( "** %s ** **%s** (%s)\n", $text, nonempty($linter['short'], '-'), $linter['name']); if ($linter['exception']) { $console->writeOut( "\n%s**%s**\n%s\n", $pad, get_class($linter['exception']), phutil_console_wrap( $linter['exception']->getMessage(), strlen($pad))); $print_tail = true; } $version = $linter['version']; $uri = $linter['uri']; if ($version || $uri) { $console->writeOut("\n"); $print_tail = true; } if ($version) { $console->writeOut("%s%s **%s**\n", $pad, pht('Version'), $version); } if ($uri) { $console->writeOut("%s__%s__\n", $pad, $linter['uri']); } $description = $linter['description']; if ($description) { $console->writeOut( "\n%s\n", phutil_console_wrap($linter['description'], strlen($pad))); $print_tail = true; } $options = $linter['options']; if ($options && $this->getArgument('verbose')) { $console->writeOut( "\n%s**%s**\n\n", $pad, pht('Configuration Options')); $last_option = last_key($options); foreach ($options as $option => $option_spec) { $console->writeOut( "%s__%s__ (%s)\n", $pad, $option, $option_spec['type']); $console->writeOut( "%s\n", phutil_console_wrap( $option_spec['help'], strlen($pad) + 2)); if ($option != $last_option) { $console->writeOut("\n"); } } $print_tail = true; } if ($print_tail) { $console->writeOut("\n"); } } if (!$this->getArgument('verbose')) { $console->writeOut( "%s\n", pht('(Run `%s` for more details.)', 'arc linters --verbose')); } } /** * Get human-readable linter statuses, padded to fixed width. * * @return map Human-readable linter status names. */ private function getStatusMap() { $text_map = array( 'configured' => pht('CONFIGURED'), 'available' => pht('AVAILABLE'), 'error' => pht('ERROR'), ); $sizes = array(); foreach ($text_map as $key => $string) { $sizes[$key] = phutil_utf8_console_strlen($string); } $longest = max($sizes); foreach ($text_map as $key => $string) { if ($sizes[$key] < $longest) { $text_map[$key] .= str_repeat(' ', $longest - $sizes[$key]); } } $text_map['padding'] = str_repeat(' ', $longest); return $text_map; } }