diff --git a/src/exception/usage/ArcanistUncommittedChangesException.php b/src/exception/usage/ArcanistUncommittedChangesException.php index 488150b0..5baedf52 100644 --- a/src/exception/usage/ArcanistUncommittedChangesException.php +++ b/src/exception/usage/ArcanistUncommittedChangesException.php @@ -1,5 +1,4 @@ lintResult = $result; return $obj; } public function getUnmodifiedFileContent() { return $this->lintResult->getData(); } public function getModifiedFileContent() { if ($this->modifiedData === null) { $this->buildModifiedFile(); } return $this->modifiedData; } public function writePatchToDisk() { $path = $this->lintResult->getFilePathOnDisk(); $data = $this->getModifiedFileContent(); $ii = null; do { $lint = $path.'.linted'.($ii++); } while (file_exists($lint)); // Copy existing file to preserve permissions. 'chmod --reference' is not // supported under OSX. if (Filesystem::pathExists($path)) { // This path may not exist if we're generating a new file. execx('cp -p %s %s', $path, $lint); } Filesystem::writeFile($lint, $data); list($err) = exec_manual('mv -f %s %s', $lint, $path); if ($err) { throw new Exception( "Unable to overwrite path `{$path}', patched version was left ". "at `{$lint}'."); } foreach ($this->applyMessages as $message) { $message->didApplyPatch(); } } - private function __construct() { - - } + private function __construct() {} private function buildModifiedFile() { $data = $this->getUnmodifiedFileContent(); foreach ($this->lintResult->getMessages() as $lint) { if (!$lint->isPatchable()) { continue; } $orig_offset = $this->getCharacterOffset($lint->getLine() - 1); $orig_offset += $lint->getChar() - 1; $dirty = $this->getDirtyCharacterOffset(); if ($dirty > $orig_offset) { continue; } // Adjust the character offset by the delta *after* checking for // dirtiness. The dirty character cursor is a cursor on the original file, // and should be compared with the patch position in the original file. $working_offset = $orig_offset + $this->getCharacterDelta(); $old_str = $lint->getOriginalText(); $old_len = strlen($old_str); $new_str = $lint->getReplacementText(); $new_len = strlen($new_str); if ($working_offset == strlen($data)) { // Temporary hack to work around a destructive hphpi issue, see #451031. $data .= $new_str; } else { $data = substr_replace($data, $new_str, $working_offset, $old_len); } $this->changeCharacterDelta($new_len - $old_len); $this->setDirtyCharacterOffset($orig_offset + $old_len); $this->applyMessages[] = $lint; } $this->modifiedData = $data; } private function getCharacterOffset($line_num) { if ($this->lineOffsets === null) { $lines = explode("\n", $this->getUnmodifiedFileContent()); $this->lineOffsets = array(0); $last = 0; foreach ($lines as $line) { $this->lineOffsets[] = $last + strlen($line) + 1; $last += strlen($line) + 1; } } if ($line_num >= count($this->lineOffsets)) { throw new Exception("Data has fewer than `{$line}' lines."); } return idx($this->lineOffsets, $line_num); } private function setDirtyCharacterOffset($offset) { $this->dirtyUntil = $offset; return $this; } private function getDirtyCharacterOffset() { return $this->dirtyUntil; } private function changeCharacterDelta($change) { $this->characterDelta += $change; return $this; } private function getCharacterDelta() { return $this->characterDelta; } } diff --git a/src/lint/engine/ArcanistLintEngine.php b/src/lint/engine/ArcanistLintEngine.php index 6ffa4f7c..f32f640a 100644 --- a/src/lint/engine/ArcanistLintEngine.php +++ b/src/lint/engine/ArcanistLintEngine.php @@ -1,584 +1,582 @@ configurationManager = $configuration_manager; return $this; } final public function getConfigurationManager() { return $this->configurationManager; } final public function setWorkingCopy( ArcanistWorkingCopyIdentity $working_copy) { $this->workingCopy = $working_copy; return $this; } final public function getWorkingCopy() { return $this->workingCopy; } final public function setPaths($paths) { $this->paths = $paths; return $this; } public function getPaths() { return $this->paths; } final public function setPathChangedLines($path, $changed) { if ($changed === null) { $this->changedLines[$path] = null; } else { $this->changedLines[$path] = array_fill_keys($changed, true); } return $this; } final public function getPathChangedLines($path) { return idx($this->changedLines, $path); } final public function setFileData($data) { $this->fileData = $data + $this->fileData; return $this; } final public function setCommitHookMode($mode) { $this->commitHookMode = $mode; return $this; } final public function setHookAPI(ArcanistHookAPI $hook_api) { $this->hookAPI = $hook_api; return $this; } final public function getHookAPI() { return $this->hookAPI; } final public function setEnableAsyncLint($enable_async_lint) { $this->enableAsyncLint = $enable_async_lint; return $this; } final public function getEnableAsyncLint() { return $this->enableAsyncLint; } final public function loadData($path) { if (!isset($this->fileData[$path])) { if ($this->getCommitHookMode()) { $this->fileData[$path] = $this->getHookAPI() ->getCurrentFileData($path); } else { $disk_path = $this->getFilePathOnDisk($path); $this->fileData[$path] = Filesystem::readFile($disk_path); } } return $this->fileData[$path]; } public function pathExists($path) { if ($this->getCommitHookMode()) { $file_data = $this->loadData($path); return ($file_data !== null); } else { $disk_path = $this->getFilePathOnDisk($path); return Filesystem::pathExists($disk_path); } } final public function isDirectory($path) { if ($this->getCommitHookMode()) { // TODO: This won't get the right result in every case (we need more // metadata) but should almost always be correct. try { $this->loadData($path); return false; } catch (Exception $ex) { return true; } } else { $disk_path = $this->getFilePathOnDisk($path); return is_dir($disk_path); } } final public function isBinaryFile($path) { try { $data = $this->loadData($path); } catch (Exception $ex) { return false; } return ArcanistDiffUtils::isHeuristicBinaryFile($data); } final public function isSymbolicLink($path) { return is_link($this->getFilePathOnDisk($path)); } final public function getFilePathOnDisk($path) { return Filesystem::resolvePath( $path, $this->getWorkingCopy()->getProjectRoot()); } final public function setMinimumSeverity($severity) { $this->minimumSeverity = $severity; return $this; } final public function getCommitHookMode() { return $this->commitHookMode; } final public function run() { $linters = $this->buildLinters(); if (!$linters) { throw new ArcanistNoEffectException('No linters to run.'); } $linters = msort($linters, 'getLinterPriority'); foreach ($linters as $linter) { $linter->setEngine($this); } $have_paths = false; foreach ($linters as $linter) { if ($linter->getPaths()) { $have_paths = true; break; } } if (!$have_paths) { throw new ArcanistNoEffectException('No paths are lintable.'); } $versions = array($this->getCacheVersion()); foreach ($linters as $linter) { $version = get_class($linter).':'.$linter->getCacheVersion(); $symbols = id(new PhutilSymbolLoader()) ->setType('class') ->setName(get_class($linter)) ->selectSymbolsWithoutLoading(); $symbol = idx($symbols, 'class$'.get_class($linter)); if ($symbol) { $version .= ':'.md5_file( phutil_get_library_root($symbol['library']).'/'.$symbol['where']); } $versions[] = $version; } $this->cacheVersion = crc32(implode("\n", $versions)); $this->stopped = array(); $exceptions = array(); foreach ($linters as $linter_name => $linter) { if (!is_string($linter_name)) { $linter_name = get_class($linter); } try { if (!$linter->canRun()) { continue; } $paths = $linter->getPaths(); foreach ($paths as $key => $path) { // Make sure each path has a result generated, even if it is empty // (i.e., the file has no lint messages). $result = $this->getResultForPath($path); if (isset($this->stopped[$path])) { unset($paths[$key]); } if (isset($this->cachedResults[$path][$this->cacheVersion])) { $cached_result = $this->cachedResults[$path][$this->cacheVersion]; $use_cache = $this->shouldUseCache( $linter->getCacheGranularity(), idx($cached_result, 'repository_version')); if ($use_cache) { unset($paths[$key]); if (idx($cached_result, 'stopped') == $linter_name) { $this->stopped[$path] = $linter_name; } } } } $paths = array_values($paths); if ($paths) { $profiler = PhutilServiceProfiler::getInstance(); $call_id = $profiler->beginServiceCall(array( 'type' => 'lint', 'linter' => $linter_name, 'paths' => $paths, )); try { $linter->willLintPaths($paths); foreach ($paths as $path) { $linter->willLintPath($path); $linter->lintPath($path); if ($linter->didStopAllLinters()) { $this->stopped[$path] = $linter_name; } } } catch (Exception $ex) { $profiler->endServiceCall($call_id, array()); throw $ex; } $profiler->endServiceCall($call_id, array()); } } catch (Exception $ex) { $exceptions[$linter_name] = $ex; } } $exceptions += $this->didRunLinters($linters); foreach ($linters as $linter) { foreach ($linter->getLintMessages() as $message) { if (!$this->isSeverityEnabled($message->getSeverity())) { continue; } if (!$this->isRelevantMessage($message)) { continue; } $message->setGranularity($linter->getCacheGranularity()); $result = $this->getResultForPath($message->getPath()); $result->addMessage($message); } } if ($this->cachedResults) { foreach ($this->cachedResults as $path => $messages) { $messages = idx($messages, $this->cacheVersion, array()); $repository_version = idx($messages, 'repository_version'); unset($messages['stopped']); unset($messages['repository_version']); foreach ($messages as $message) { $use_cache = $this->shouldUseCache( idx($message, 'granularity'), $repository_version); if ($use_cache) { $this->getResultForPath($path)->addMessage( ArcanistLintMessage::newFromDictionary($message)); } } } } foreach ($this->results as $path => $result) { $disk_path = $this->getFilePathOnDisk($path); $result->setFilePathOnDisk($disk_path); if (isset($this->fileData[$path])) { $result->setData($this->fileData[$path]); } else if ($disk_path && Filesystem::pathExists($disk_path)) { // TODO: this may cause us to, e.g., load a large binary when we only // raised an error about its filename. We could refine this by looking // through the lint messages and doing this load only if any of them // have original/replacement text or something like that. try { $this->fileData[$path] = Filesystem::readFile($disk_path); $result->setData($this->fileData[$path]); } catch (FilesystemException $ex) { // Ignore this, it's noncritical that we access this data and it // might be unreadable or a directory or whatever else for plenty // of legitimate reasons. } } } if ($exceptions) { throw new PhutilAggregateException('Some linters failed:', $exceptions); } return $this->results; } final public function isSeverityEnabled($severity) { $minimum = $this->minimumSeverity; return ArcanistLintSeverity::isAtLeastAsSevere($severity, $minimum); } final private function shouldUseCache( $cache_granularity, $repository_version) { if ($this->commitHookMode) { return false; } switch ($cache_granularity) { case ArcanistLinter::GRANULARITY_FILE: return true; case ArcanistLinter::GRANULARITY_DIRECTORY: case ArcanistLinter::GRANULARITY_REPOSITORY: return ($this->repositoryVersion == $repository_version); default: return false; } } /** * @param dict>> * @return this */ final public function setCachedResults(array $results) { $this->cachedResults = $results; return $this; } final public function getResults() { return $this->results; } final public function getStoppedPaths() { return $this->stopped; } abstract protected function buildLinters(); final protected function didRunLinters(array $linters) { assert_instances_of($linters, 'ArcanistLinter'); $exceptions = array(); $profiler = PhutilServiceProfiler::getInstance(); foreach ($linters as $linter_name => $linter) { if (!is_string($linter_name)) { $linter_name = get_class($linter); } $call_id = $profiler->beginServiceCall(array( 'type' => 'lint', 'linter' => $linter_name, )); try { $linter->didRunLinters(); } catch (Exception $ex) { $exceptions[$linter_name] = $ex; } $profiler->endServiceCall($call_id, array()); } return $exceptions; } final public function setRepositoryVersion($version) { $this->repositoryVersion = $version; return $this; } final private function isRelevantMessage(ArcanistLintMessage $message) { // When a user runs "arc lint", we default to raising only warnings on // lines they have changed (errors are still raised anywhere in the // file). The list of $changed lines may be null, to indicate that the // path is a directory or a binary file so we should not exclude // warnings. if (!$this->changedLines || $message->isError() || $message->shouldBypassChangedLineFiltering()) { return true; } $locations = $message->getOtherLocations(); $locations[] = $message->toDictionary(); foreach ($locations as $location) { $path = idx($location, 'path', $message->getPath()); if (!array_key_exists($path, $this->changedLines)) { continue; } $changed = $this->getPathChangedLines($path); if ($changed === null || !$location['line']) { return true; } $last_line = $location['line']; if (isset($location['original'])) { $last_line += substr_count($location['original'], "\n"); } for ($l = $location['line']; $l <= $last_line; $l++) { if (!empty($changed[$l])) { return true; } } } return false; } final protected function getResultForPath($path) { if (empty($this->results[$path])) { $result = new ArcanistLintResult(); $result->setPath($path); $result->setCacheVersion($this->cacheVersion); $this->results[$path] = $result; } return $this->results[$path]; } final public function getLineAndCharFromOffset($path, $offset) { if (!isset($this->charToLine[$path])) { $char_to_line = array(); $line_to_first_char = array(); $lines = explode("\n", $this->loadData($path)); $line_number = 0; $line_start = 0; foreach ($lines as $line) { $len = strlen($line) + 1; // Account for "\n". $line_to_first_char[] = $line_start; $line_start += $len; for ($ii = 0; $ii < $len; $ii++) { $char_to_line[] = $line_number; } $line_number++; } $this->charToLine[$path] = $char_to_line; $this->lineToFirstChar[$path] = $line_to_first_char; } $line = $this->charToLine[$path][$offset]; $char = $offset - $this->lineToFirstChar[$path][$line]; return array($line, $char); } final public function getPostponedLinters() { return $this->postponedLinters; } final public function setPostponedLinters(array $linters) { $this->postponedLinters = $linters; return $this; } protected function getCacheVersion() { return 1; } /** * Get a named linter resource shared by another linter. * * This mechanism allows linters to share arbitrary resources, like the * results of computation. If several linters need to perform the same * expensive computation step, they can use a named resource to synchronize * construction of the result so it doesn't need to be built multiple * times. * * @param string Resource identifier. * @param wild Optionally, default value to return if resource does not * exist. * @return wild Resource, or default value if not present. */ public function getLinterResource($key, $default = null) { return idx($this->linterResources, $key, $default); } /** * Set a linter resource that other linters can access. * * See @{method:getLinterResource} for a description of this mechanism. * * @param string Resource identifier. * @param wild Resource. * @return this */ public function setLinterResource($key, $value) { $this->linterResources[$key] = $value; return $this; } } diff --git a/src/unit/engine/ArcanistUnitTestEngine.php b/src/unit/engine/ArcanistUnitTestEngine.php index 4153f00c..c449ed6f 100644 --- a/src/unit/engine/ArcanistUnitTestEngine.php +++ b/src/unit/engine/ArcanistUnitTestEngine.php @@ -1,122 +1,120 @@ supportsRunAllTests() && $run_all_tests) { $class = get_class($this); throw new Exception( "Engine '{$class}' does not support --everything."); } $this->runAllTests = $run_all_tests; return $this; } public function getRunAllTests() { return $this->runAllTests; } protected function supportsRunAllTests() { return false; } - final public function __construct() { - - } + final public function __construct() {} public function setConfigurationManager( ArcanistConfigurationManager $configuration_manager) { $this->configurationManager = $configuration_manager; return $this; } public function getConfigurationManager() { return $this->configurationManager; } final public function setWorkingCopy( ArcanistWorkingCopyIdentity $working_copy) { // TODO: Remove this once ArcanistBaseUnitTestEngine is gone. if ($this instanceof ArcanistBaseUnitTestEngine) { phutil_deprecated( 'ArcanistBaseUnitTestEngine', 'You should extend from `ArcanistUnitTestEngine` instead.'); } $this->workingCopy = $working_copy; return $this; } final public function getWorkingCopy() { return $this->workingCopy; } final public function setPaths(array $paths) { $this->paths = $paths; return $this; } final public function getPaths() { return $this->paths; } final public function setArguments(array $arguments) { $this->arguments = $arguments; return $this; } final public function getArgument($key, $default = null) { return idx($this->arguments, $key, $default); } final public function setEnableAsyncTests($enable_async_tests) { $this->enableAsyncTests = $enable_async_tests; return $this; } final public function getEnableAsyncTests() { return $this->enableAsyncTests; } final public function setEnableCoverage($enable_coverage) { $this->enableCoverage = $enable_coverage; return $this; } final public function getEnableCoverage() { return $this->enableCoverage; } public function setRenderer(ArcanistUnitRenderer $renderer) { $this->renderer = $renderer; return $this; } abstract public function run(); /** * Modify the return value of this function in the child class, if you do * not need to echo the test results after all the tests have been run. This * is the case for example when the child class prints the tests results * while the tests are running. */ public function shouldEchoTestResults() { return true; } } diff --git a/src/workflow/ArcanistFeatureWorkflow.php b/src/workflow/ArcanistFeatureWorkflow.php index c361256f..4ffb4662 100644 --- a/src/workflow/ArcanistFeatureWorkflow.php +++ b/src/workflow/ArcanistFeatureWorkflow.php @@ -1,366 +1,365 @@ getArgument('branch'); } public function getArguments() { return array( 'view-all' => array( 'help' => 'Include closed and abandoned revisions.', ), 'by-status' => array( 'help' => 'Sort branches by status instead of time.', ), 'output' => array( 'param' => 'format', 'support' => array( 'json', ), 'help' => "With 'json', show features in machine-readable JSON format.", ), '*' => 'branch', ); } public function run() { $repository_api = $this->getRepositoryAPI(); if (!($repository_api instanceof ArcanistGitAPI) && !($repository_api instanceof ArcanistMercurialAPI)) { throw new ArcanistUsageException( 'arc feature is only supported under Git and Mercurial.'); } $names = $this->getArgument('branch'); if ($names) { if (count($names) > 2) { throw new ArcanistUsageException('Specify only one branch.'); } return $this->checkoutBranch($names); } $branches = $repository_api->getAllBranches(); if (!$branches) { throw new ArcanistUsageException('No branches in this working copy.'); } $branches = $this->loadCommitInfo($branches); $revisions = $this->loadRevisions($branches); $this->printBranches($branches, $revisions); return 0; } private function checkoutBranch(array $names) { $api = $this->getRepositoryAPI(); if ($api instanceof ArcanistMercurialAPI) { $command = 'update %s'; } else { $command = 'checkout %s'; } $err = 1; $name = $names[0]; if (isset($names[1])) { $start = $names[1]; } else { $start = $this->getConfigFromAnySource('arc.feature.start.default'); } $branches = $api->getAllBranches(); if (in_array($name, ipull($branches, 'name'))) { list($err, $stdout, $stderr) = $api->execManualLocal($command, $name); } if ($err) { $match = null; if (preg_match('/^D(\d+)$/', $name, $match)) { try { $diff = $this->getConduit()->callMethodSynchronous( 'differential.getdiff', array( 'revision_id' => $match[1], )); if ($diff['branch'] != '') { $name = $diff['branch']; list($err, $stdout, $stderr) = $api->execManualLocal( $command, $name); } - } catch (ConduitClientException $ex) { - } + } catch (ConduitClientException $ex) {} } } if ($err) { if ($api instanceof ArcanistMercurialAPI) { $rev = ''; if ($start) { $rev = csprintf('-r %s', $start); } $exec = $api->execManualLocal('bookmark %C %s', $rev, $name); if (!$exec[0] && $start) { $api->execxLocal('update %s', $name); } } else { $startarg = $start ? csprintf('%s', $start) : ''; $exec = $api->execManualLocal( 'checkout --track -b %s %C', $name, $startarg); } list($err, $stdout, $stderr) = $exec; } echo $stdout; fprintf(STDERR, $stderr); return $err; } private function loadCommitInfo(array $branches) { $repository_api = $this->getRepositoryAPI(); $futures = array(); foreach ($branches as $branch) { if ($repository_api instanceof ArcanistMercurialAPI) { $futures[$branch['name']] = $repository_api->execFutureLocal( 'log -l 1 --template %s -r %s', "{node}\1{date|hgdate}\1{p1node}\1{desc|firstline}\1{desc}", hgsprintf('%s', $branch['name'])); } else { // NOTE: "-s" is an option deep in git's diff argument parser that // doesn't seem to have much documentation and has no long form. It // suppresses any diff output. $futures[$branch['name']] = $repository_api->execFutureLocal( 'show -s --format=%C %s --', '%H%x01%ct%x01%T%x01%s%x01%s%n%n%b', $branch['name']); } } $branches = ipull($branches, null, 'name'); foreach (Futures($futures)->limit(16) as $name => $future) { list($info) = $future->resolvex(); list($hash, $epoch, $tree, $desc, $text) = explode("\1", trim($info), 5); $branch = $branches[$name] + array( 'hash' => $hash, 'desc' => $desc, 'tree' => $tree, 'epoch' => (int)$epoch, ); try { $message = ArcanistDifferentialCommitMessage::newFromRawCorpus($text); $id = $message->getRevisionID(); $branch['revisionID'] = $id; } catch (ArcanistUsageException $ex) { // In case of invalid commit message which fails the parsing, // do nothing. $branch['revisionID'] = null; } $branches[$name] = $branch; } return $branches; } private function loadRevisions(array $branches) { $ids = array(); $hashes = array(); foreach ($branches as $branch) { if ($branch['revisionID']) { $ids[] = $branch['revisionID']; } $hashes[] = array('gtcm', $branch['hash']); $hashes[] = array('gttr', $branch['tree']); } $calls = array(); if ($ids) { $calls[] = $this->getConduit()->callMethod( 'differential.query', array( 'ids' => $ids, )); } if ($hashes) { $calls[] = $this->getConduit()->callMethod( 'differential.query', array( 'commitHashes' => $hashes, )); } $results = array(); foreach (Futures($calls) as $call) { $results[] = $call->resolve(); } return array_mergev($results); } private function printBranches(array $branches, array $revisions) { $revisions = ipull($revisions, null, 'id'); static $color_map = array( 'Closed' => 'cyan', 'Needs Review' => 'magenta', 'Needs Revision' => 'red', 'Accepted' => 'green', 'No Revision' => 'blue', 'Abandoned' => 'default', ); static $ssort_map = array( 'Closed' => 1, 'No Revision' => 2, 'Needs Review' => 3, 'Needs Revision' => 4, 'Accepted' => 5, ); $out = array(); foreach ($branches as $branch) { $revision = idx($revisions, idx($branch, 'revisionID')); // If we haven't identified a revision by ID, try to identify it by hash. if (!$revision) { foreach ($revisions as $rev) { $hashes = idx($rev, 'hashes', array()); foreach ($hashes as $hash) { if (($hash[0] == 'gtcm' && $hash[1] == $branch['hash']) || ($hash[0] == 'gttr' && $hash[1] == $branch['tree'])) { $revision = $rev; break; } } } } if ($revision) { $desc = 'D'.$revision['id'].': '.$revision['title']; $status = $revision['statusName']; } else { $desc = $branch['desc']; $status = 'No Revision'; } if (!$this->getArgument('view-all') && !$branch['current']) { if ($status == 'Closed' || $status == 'Abandoned') { continue; } } $epoch = $branch['epoch']; $color = idx($color_map, $status, 'default'); $ssort = sprintf('%d%012d', idx($ssort_map, $status, 0), $epoch); $out[] = array( 'name' => $branch['name'], 'current' => $branch['current'], 'status' => $status, 'desc' => $desc, 'revision' => $revision ? $revision['id'] : null, 'color' => $color, 'esort' => $epoch, 'epoch' => $epoch, 'ssort' => $ssort, ); } $len_name = max(array_map('strlen', ipull($out, 'name'))) + 2; $len_status = max(array_map('strlen', ipull($out, 'status'))) + 2; if ($this->getArgument('by-status')) { $out = isort($out, 'ssort'); } else { $out = isort($out, 'esort'); } if ($this->getArgument('output') == 'json') { foreach ($out as &$feature) { unset($feature['color'], $feature['ssort'], $feature['esort']); } echo json_encode(ipull($out, null, 'name'))."\n"; } else { $table = id(new PhutilConsoleTable()) ->setShowHeader(false) ->addColumn('current', array('title' => '')) ->addColumn('name', array('title' => 'Name')) ->addColumn('status', array('title' => 'Status')) ->addColumn('descr', array('title' => 'Description')); foreach ($out as $line) { $table->addRow(array( 'current' => $line['current'] ? '*' : '', 'name' => phutil_console_format('**%s**', $line['name']), 'status' => phutil_console_format( "%s", $line['status']), 'descr' => $line['desc'], )); } $table->draw(); } } }