diff --git a/src/lint/linter/ArcanistLinter.php b/src/lint/linter/ArcanistLinter.php index 292d4438..d2695148 100644 --- a/src/lint/linter/ArcanistLinter.php +++ b/src/lint/linter/ArcanistLinter.php @@ -1,614 +1,628 @@ A mapping of header to body content for the + * additional information sections. + * @task info + */ + public function getAdditionalInformation() { + return array(); + } + /** * Return a human-readable linter name. * * These are used by `arc linters`, and can let you give a linter a more * presentable name. * * @return string Human-readable linter name. * @task info */ public function getInfoName() { return nonempty( $this->getLinterName(), $this->getLinterConfigurationName(), get_class($this)); } /* -( Runtime State )------------------------------------------------------ */ /** * @task state */ final public function getActivePath() { return $this->activePath; } /** * @task state */ final public function setActivePath($path) { $this->stopAllLinters = false; $this->activePath = $path; return $this; } /** * @task state */ final public function setEngine(ArcanistLintEngine $engine) { $this->engine = $engine; return $this; } /** * @task state */ final protected function getEngine() { return $this->engine; } /** * Set the internal ID for this linter. * * This ID is assigned automatically by the @{class:ArcanistLintEngine}. * * @param string Unique linter ID. * @return this * @task state */ final public function setLinterID($id) { $this->id = $id; return $this; } /** * Get the internal ID for this linter. * * Retrieves an internal linter ID managed by the @{class:ArcanistLintEngine}. * This ID is a unique scalar which distinguishes linters in a list. * * @return string Unique linter ID. * @task state */ final public function getLinterID() { return $this->id; } /* -( Executing Linters )-------------------------------------------------- */ /** * Hook called before a list of paths are linted. * * Parallelizable linters can start multiple requests in parallel here, * to improve performance. They can implement @{method:didLintPaths} to * collect results. * * Linters which are not parallelizable should normally ignore this callback * and implement @{method:lintPath} instead. * * @param list A list of paths to be linted * @return void * @task exec */ public function willLintPaths(array $paths) { return; } /** * Hook called for each path to be linted. * * Linters which are not parallelizable can do work here. * * Linters which are parallelizable may want to ignore this callback and * implement @{method:willLintPaths} and @{method:didLintPaths} instead. * * @param string Path to lint. * @return void * @task exec */ public function lintPath($path) { return; } /** * Hook called after a list of paths are linted. * * Parallelizable linters can collect results here. * * Linters which are not paralleizable should normally ignore this callback * and implement @{method:lintPath} instead. * * @param list A list of paths which were linted. * @return void * @task exec */ public function didLintPaths(array $paths) { return; } public function getLinterPriority() { return 1.0; } /** * TODO: This should be `final`. */ public function setCustomSeverityMap(array $map) { $this->customSeverityMap = $map; return $this; } public function addCustomSeverityMap(array $map) { $this->customSeverityMap = $this->customSeverityMap + $map; return $this; } final public function setCustomSeverityRules(array $rules) { $this->customSeverityRules = $rules; return $this; } final public function getProjectRoot() { $engine = $this->getEngine(); if (!$engine) { throw new Exception( pht( 'You must call %s before you can call %s.', 'setEngine()', __FUNCTION__.'()')); } $working_copy = $engine->getWorkingCopy(); if (!$working_copy) { return null; } return $working_copy->getProjectRoot(); } final public function getOtherLocation($offset, $path = null) { if ($path === null) { $path = $this->getActivePath(); } list($line, $char) = $this->getEngine()->getLineAndCharFromOffset( $path, $offset); return array( 'path' => $path, 'line' => $line + 1, 'char' => $char, ); } final public function stopAllLinters() { $this->stopAllLinters = true; return $this; } final public function didStopAllLinters() { return $this->stopAllLinters; } final public function addPath($path) { $this->paths[$path] = $path; $this->filteredPaths = null; return $this; } final public function setPaths(array $paths) { $this->paths = $paths; $this->filteredPaths = null; return $this; } /** * Filter out paths which this linter doesn't act on (for example, because * they are binaries and the linter doesn't apply to binaries). * * @param list * @return list */ final private function filterPaths(array $paths) { $engine = $this->getEngine(); $keep = array(); foreach ($paths as $path) { if (!$this->shouldLintDeletedFiles() && !$engine->pathExists($path)) { continue; } if (!$this->shouldLintDirectories() && $engine->isDirectory($path)) { continue; } if (!$this->shouldLintBinaryFiles() && $engine->isBinaryFile($path)) { continue; } if (!$this->shouldLintSymbolicLinks() && $engine->isSymbolicLink($path)) { continue; } $keep[] = $path; } return $keep; } final public function getPaths() { if ($this->filteredPaths === null) { $this->filteredPaths = $this->filterPaths(array_values($this->paths)); } return $this->filteredPaths; } final public function addData($path, $data) { $this->data[$path] = $data; return $this; } final protected function getData($path) { if (!array_key_exists($path, $this->data)) { $this->data[$path] = $this->getEngine()->loadData($path); } return $this->data[$path]; } public function getCacheVersion() { return 0; } final public function getLintMessageFullCode($short_code) { return $this->getLinterName().$short_code; } final public function getLintMessageSeverity($code) { $map = $this->customSeverityMap; if (isset($map[$code])) { return $map[$code]; } foreach ($this->customSeverityRules as $rule => $severity) { if (preg_match($rule, $code)) { return $severity; } } $map = $this->getLintSeverityMap(); if (isset($map[$code])) { return $map[$code]; } return $this->getDefaultMessageSeverity($code); } protected function getDefaultMessageSeverity($code) { return ArcanistLintSeverity::SEVERITY_ERROR; } final public function isMessageEnabled($code) { return ($this->getLintMessageSeverity($code) !== ArcanistLintSeverity::SEVERITY_DISABLED); } final public function getLintMessageName($code) { $map = $this->getLintNameMap(); if (isset($map[$code])) { return $map[$code]; } return pht('Unknown lint message!'); } final protected function addLintMessage(ArcanistLintMessage $message) { $root = $this->getProjectRoot(); $path = Filesystem::resolvePath($message->getPath(), $root); $message->setPath(Filesystem::readablePath($path, $root)); $this->messages[] = $message; return $message; } final public function getLintMessages() { return $this->messages; } final public function raiseLintAtLine( $line, $char, $code, $description, $original = null, $replacement = null) { $message = id(new ArcanistLintMessage()) ->setPath($this->getActivePath()) ->setLine($line) ->setChar($char) ->setCode($this->getLintMessageFullCode($code)) ->setSeverity($this->getLintMessageSeverity($code)) ->setName($this->getLintMessageName($code)) ->setDescription($description) ->setOriginalText($original) ->setReplacementText($replacement); return $this->addLintMessage($message); } final public function raiseLintAtPath($code, $desc) { return $this->raiseLintAtLine(null, null, $code, $desc, null, null); } final public function raiseLintAtOffset( $offset, $code, $description, $original = null, $replacement = null) { $path = $this->getActivePath(); $engine = $this->getEngine(); if ($offset === null) { $line = null; $char = null; } else { list($line, $char) = $engine->getLineAndCharFromOffset($path, $offset); } return $this->raiseLintAtLine( $line + 1, $char + 1, $code, $description, $original, $replacement); } public function canRun() { return true; } abstract public function getLinterName(); public function getVersion() { return null; } final protected function isCodeEnabled($code) { $severity = $this->getLintMessageSeverity($code); return $this->getEngine()->isSeverityEnabled($severity); } public function getLintSeverityMap() { return array(); } public function getLintNameMap() { return array(); } public function getCacheGranularity() { return self::GRANULARITY_FILE; } /** * If this linter is selectable via `.arclint` configuration files, return * a short, human-readable name to identify it. For example, `"jshint"` or * `"pep8"`. * * If you do not implement this method, the linter will not be selectable * through `.arclint` files. */ public function getLinterConfigurationName() { return null; } public function getLinterConfigurationOptions() { if (!$this->canCustomizeLintSeverities()) { return array(); } return array( 'severity' => array( 'type' => 'optional map', 'help' => pht( 'Provide a map from lint codes to adjusted severity levels: error, '. 'warning, advice, autofix or disabled.'), ), 'severity.rules' => array( 'type' => 'optional map', 'help' => pht( 'Provide a map of regular expressions to severity levels. All '. 'matching codes have their severity adjusted.'), ), 'standard' => array( 'type' => 'optional string | list', 'help' => pht('The coding standard(s) to apply.'), ), ); } public function setLinterConfigurationValue($key, $value) { $sev_map = array( 'error' => ArcanistLintSeverity::SEVERITY_ERROR, 'warning' => ArcanistLintSeverity::SEVERITY_WARNING, 'autofix' => ArcanistLintSeverity::SEVERITY_AUTOFIX, 'advice' => ArcanistLintSeverity::SEVERITY_ADVICE, 'disabled' => ArcanistLintSeverity::SEVERITY_DISABLED, ); switch ($key) { case 'severity': if (!$this->canCustomizeLintSeverities()) { break; } $custom = array(); foreach ($value as $code => $severity) { if (empty($sev_map[$severity])) { $valid = implode(', ', array_keys($sev_map)); throw new Exception( pht( 'Unknown lint severity "%s". Valid severities are: %s.', $severity, $valid)); } $code = $this->getLintCodeFromLinterConfigurationKey($code); $custom[$code] = $severity; } $this->setCustomSeverityMap($custom); return; case 'severity.rules': if (!$this->canCustomizeLintSeverities()) { break; } foreach ($value as $rule => $severity) { if (@preg_match($rule, '') === false) { throw new Exception( pht( 'Severity rule "%s" is not a valid regular expression.', $rule)); } if (empty($sev_map[$severity])) { $valid = implode(', ', array_keys($sev_map)); throw new Exception( pht( 'Unknown lint severity "%s". Valid severities are: %s.', $severity, $valid)); } } $this->setCustomSeverityRules($value); return; case 'standard': $standards = (array)$value; foreach ($standards as $standard) { $standard = ArcanistLinterStandard::getStandard($value, $this); foreach ($standard->getLinterConfiguration() as $k => $v) { $this->setLinterConfigurationValue($k, $v); } $this->addCustomSeverityMap($standard->getLinterSeverityMap()); } return; } throw new Exception(pht('Incomplete implementation: %s!', $key)); } protected function canCustomizeLintSeverities() { return true; } protected function shouldLintBinaryFiles() { return false; } protected function shouldLintDeletedFiles() { return false; } protected function shouldLintDirectories() { return false; } protected function shouldLintSymbolicLinks() { return false; } /** * Map a configuration lint code to an `arc` lint code. Primarily, this is * intended for validation, but can also be used to normalize case or * otherwise be more permissive in accepted inputs. * * If the code is not recognized, you should throw an exception. * * @param string Code specified in configuration. * @return string Normalized code to use in severity map. */ protected function getLintCodeFromLinterConfigurationKey($code) { return $code; } } diff --git a/src/lint/linter/ArcanistXHPASTLinter.php b/src/lint/linter/ArcanistXHPASTLinter.php index aa3a472b..636d8569 100644 --- a/src/lint/linter/ArcanistXHPASTLinter.php +++ b/src/lint/linter/ArcanistXHPASTLinter.php @@ -1,138 +1,161 @@ setRules(ArcanistXHPASTLinterRule::loadAllRules()); } public function __clone() { $rules = $this->rules; $this->rules = array(); foreach ($rules as $rule) { $this->rules[] = clone $rule; } } /** * Set the XHPAST linter rules which are enforced by this linter. * * This is primarily useful for unit tests in which it is desirable to test * linter rules in isolation. By default, all linter rules will be enabled. * * @param list * @return this */ public function setRules(array $rules) { assert_instances_of($rules, 'ArcanistXHPASTLinterRule'); $this->rules = $rules; return $this; } public function getInfoName() { return pht('XHPAST Lint'); } public function getInfoDescription() { return pht('Use XHPAST to enforce coding conventions on PHP source files.'); } + public function getAdditionalInformation() { + $table = id(new PhutilConsoleTable()) + ->setBorders(true) + ->addColumn('id', array('title' => pht('ID'))) + ->addColumn('class', array('title' => pht('Class'))) + ->addColumn('name', array('title' => pht('Name'))); + + $rules = $this->rules; + ksort($rules); + + foreach ($rules as $id => $rule) { + $table->addRow(array( + 'id' => $id, + 'class' => get_class($rule), + 'name' => $rule->getLintName(), + )); + } + + return array( + pht('Linter Rules') => $table->drawConsoleString(), + ); + } + public function getLinterName() { return 'XHP'; } public function getLinterConfigurationName() { return 'xhpast'; } public function getLintNameMap() { if ($this->lintNameMap === null) { $this->lintNameMap = mpull( $this->rules, 'getLintName', 'getLintID'); } return $this->lintNameMap; } public function getLintSeverityMap() { if ($this->lintSeverityMap === null) { $this->lintSeverityMap = mpull( $this->rules, 'getLintSeverity', 'getLintID'); } return $this->lintSeverityMap; } public function getLinterConfigurationOptions() { return parent::getLinterConfigurationOptions() + array_mergev( mpull($this->rules, 'getLinterConfigurationOptions')); } public function setLinterConfigurationValue($key, $value) { $matched = false; foreach ($this->rules as $rule) { foreach ($rule->getLinterConfigurationOptions() as $k => $spec) { if ($k == $key) { $matched = true; $rule->setLinterConfigurationValue($key, $value); } } } if ($matched) { return; } return parent::setLinterConfigurationValue($key, $value); } public function getVersion() { // TODO: Improve this. return count($this->rules); } protected function resolveFuture($path, Future $future) { $tree = $this->getXHPASTTreeForPath($path); if (!$tree) { $ex = $this->getXHPASTExceptionForPath($path); if ($ex instanceof XHPASTSyntaxErrorException) { $this->raiseLintAtLine( $ex->getErrorLine(), 1, ArcanistSyntaxErrorXHPASTLinterRule::ID, pht( 'This file contains a syntax error: %s', $ex->getMessage())); } else if ($ex instanceof Exception) { $this->raiseLintAtPath( ArcanistUnableToParseXHPASTLinterRule::ID, $ex->getMessage()); } return; } $root = $tree->getRootNode(); foreach ($this->rules as $rule) { if ($this->isCodeEnabled($rule->getLintID())) { $rule->setLinter($this); $rule->process($root); } } } } diff --git a/src/workflow/ArcanistLintersWorkflow.php b/src/workflow/ArcanistLintersWorkflow.php index 3c7d5606..9932f3b9 100644 --- a/src/workflow/ArcanistLintersWorkflow.php +++ b/src/workflow/ArcanistLintersWorkflow.php @@ -1,292 +1,306 @@ array( 'help' => pht('Show detailed information, including options.'), ), 'search' => array( 'param' => 'search', 'repeat' => true, 'help' => pht( 'Search for linters. Search is case-insensitive, and is performed'. 'against name and description of each linter.'), ), '*' => 'exact', ); } public function run() { $console = PhutilConsole::getConsole(); $linters = id(new PhutilClassMapQuery()) ->setAncestorClass('ArcanistLinter') ->execute(); try { $built = $this->newLintEngine()->buildLinters(); } catch (ArcanistNoEngineException $ex) { $built = array(); } $linter_info = $this->getLintersInfo($linters, $built); $status_map = $this->getStatusMap(); $pad = ' '; $color_map = array( 'configured' => 'green', 'available' => 'yellow', 'error' => 'red', ); $is_verbose = $this->getArgument('verbose'); $exact = $this->getArgument('exact'); $search_terms = $this->getArgument('search'); if ($exact && $search_terms) { throw new ArcanistUsageException( 'Specify either search expression or exact name'); } if ($exact) { $linter_info = $this->findExactNames($linter_info, $exact); + if (!$linter_info) { $console->writeOut( "%s\n", pht( 'No match found. Try `%s %s` to search for a linter.', 'arc linters --search', $exact[0])); return; } $is_verbose = true; } if ($search_terms) { $linter_info = $this->filterByNames($linter_info, $search_terms); } 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['name'], '-'), $linter['short']); 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; } if ($is_verbose) { $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) { $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; } + $additional = $linter['additional']; + foreach ($additional as $title => $body) { + $console->writeOut( + "\n%s**%s**\n\n", + $pad, + $title); + + // TODO: This should maybe use `tsprintf`. + // See some discussion in D14563. + echo $body; + } + if ($print_tail) { $console->writeOut("\n"); } } } if (!$is_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; } private function getLintersInfo(array $linters, array $built) { // 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( 'name' => $linter->getLinterConfigurationName(), 'class' => get_class($linter), 'status' => $status, 'version' => $version, 'short' => $linter->getInfoName(), 'uri' => $linter->getInfoURI(), 'description' => $linter->getInfoDescription(), 'exception' => $exception, 'options' => $linter->getLinterConfigurationOptions(), + 'additional' => $linter->getAdditionalInformation(), ); } return isort($linter_info, 'short'); } private function filterByNames(array $linters, array $search_terms) { $filtered = array(); foreach ($linters as $key => $linter) { $name = $linter['name']; $short = $linter['short']; $description = $linter['description']; foreach ($search_terms as $term) { if (stripos($name, $term) !== false || stripos($short, $term) !== false || stripos($description, $term) !== false) { $filtered[$key] = $linter; } } } return $filtered; } private function findExactNames(array $linters, array $names) { $filtered = array(); foreach ($linters as $key => $linter) { $name = $linter['name']; foreach ($names as $term) { if (strcasecmp($name, $term) == 0) { $filtered[$key] = $linter; } } } return $filtered; } }