diff --git a/.divinerconfig b/.divinerconfig index 11a99d8c..f36ed9c8 100644 --- a/.divinerconfig +++ b/.divinerconfig @@ -1,9 +1,19 @@ { "name" : "Arcanist", "src_base" : "https://github.com/facebook/arcanist/blob/master", "groups" : { "intro" : "Introduction", - "config" : "Setup & Configuration" + "config" : "Setup & Configuration", + "workflow" : "Workflows", + "lint" : "Lint Integration", + "linter" : "Linters", + "unit" : "Unit Test Integration", + "unitrun" : "Unit Test Runners", + "diff" : "Diff and Changeset APIs", + "differential" : "Differential Integration", + "workingcopy" : "Working Copy APIs", + "module" : "Phutil Module System", + "testcase" : "Test Cases" } } diff --git a/scripts/arcanist.php b/scripts/arcanist.php index b4de5327..dfd7309e 100755 --- a/scripts/arcanist.php +++ b/scripts/arcanist.php @@ -1,236 +1,238 @@ #!/usr/bin/env php $arg) { if ($arg == '--') { break; } else if ($arg == '--trace') { unset($args[$key]); $config_trace_mode = true; } else if ($arg == '--no-ansi') { unset($args[$key]); PhutilConsoleFormatter::disableANSI(true); } else if (preg_match('/^--load-phutil-library=(.*)$/', $arg, $matches)) { unset($args[$key]); $load['?'] = $matches[1]; } else if (preg_match('/^--conduit-uri=(.*)$/', $arg, $matches)) { unset($args[$key]); $force_conduit = $matches[1]; } } if (!posix_isatty(STDOUT)) { PhutilConsoleFormatter::disableANSI(true); } $args = array_values($args); try { if ($config_trace_mode) { ExecFuture::pushEchoMode(true); } if (!$args) { throw new ArcanistUsageException("No command provided. Try 'arc help'."); } $working_copy = ArcanistWorkingCopyIdentity::newFromPath($_SERVER['PWD']); if ($load) { $libs = $load; } else { $libs = $working_copy->getConfig('phutil_libraries'); } if ($libs) { foreach ($libs as $name => $location) { if ($config_trace_mode) { echo "Loading phutil library '{$name}' from '{$location}'...\n"; } $library_root = Filesystem::resolvePath( $location, $working_copy->getProjectRoot()); phutil_load_library($library_root); } } $user_config = array(); $user_config_path = getenv('HOME').'/.arcrc'; if (Filesystem::pathExists($user_config_path)) { $user_config_data = Filesystem::readFile($user_config_path); $user_config = json_decode($user_config_data, true); if (!is_array($user_config)) { throw new ArcanistUsageException( "Your '~/.arcrc' file is not a valid JSON file."); } } $config = $working_copy->getConfig('arcanist_configuration'); if ($config) { PhutilSymbolLoader::loadClass($config); $config = new $config(); } else { $config = new ArcanistConfiguration(); } $command = strtolower($args[0]); $workflow = $config->buildWorkflow($command); if (!$workflow) { throw new ArcanistUsageException( "Unknown command '{$command}'. Try 'arc help'."); } $workflow->setArcanistConfiguration($config); $workflow->setCommand($command); $workflow->parseArguments(array_slice($args, 1)); $need_working_copy = $workflow->requiresWorkingCopy(); $need_conduit = $workflow->requiresConduit(); $need_auth = $workflow->requiresAuthentication(); $need_repository_api = $workflow->requiresRepositoryAPI(); $need_conduit = $need_conduit || $need_auth; $need_working_copy = $need_working_copy || $need_conduit || $need_repository_api; if ($need_working_copy) { if (!$working_copy->getProjectRoot()) { throw new ArcanistUsageException( "There is no '.arcconfig' file in this directory or any parent ". "directory. Create a '.arcconfig' file to configure this project ". "for use with Arcanist."); } $workflow->setWorkingCopy($working_copy); } $set_guid = false; if ($need_conduit) { if ($force_conduit) { $conduit_uri = $force_conduit; } else { $conduit_uri = $working_copy->getConduitURI(); } if (!$conduit_uri) { throw new ArcanistUsageException( "No Conduit URI is specified in the .arcconfig file for this project. ". "Specify the Conduit URI for the host Differential is running on."); } $conduit = new ConduitClient($conduit_uri); $conduit->setTraceMode($config_trace_mode); $workflow->setConduit($conduit); $hosts_config = idx($user_config, 'hosts', array()); $host_config = idx($hosts_config, $conduit_uri, array()); $user_name = idx($host_config, 'user', getenv('USER')); $certificate = idx($host_config, 'cert'); $description = implode(' ', $argv); $connection = $conduit->callMethodSynchronous( 'conduit.connect', array( 'client' => 'arc', 'clientVersion' => 2, 'clientDescription' => php_uname('n').':'.$description, 'user' => $user_name, 'certificate' => $certificate, )); $workflow->setUserName($user_name); $user_phid = idx($connection, 'userPHID'); if ($user_phid) { $set_guid = true; $workflow->setUserGUID($user_phid); } } if ($need_repository_api) { $repository_api = ArcanistRepositoryAPI::newAPIFromWorkingCopyIdentity( $working_copy); $workflow->setRepositoryAPI($repository_api); } if ($need_auth && !$set_guid) { $user_name = getenv('USER'); $user_find_future = $conduit->callMethod( 'user.find', array( 'aliases' => array( $user_name, ), )); $user_guids = $user_find_future->resolve(); if (empty($user_guids[$user_name])) { throw new ArcanistUsageException( "Username '{$user_name}' is not recognized."); } $user_guid = $user_guids[$user_name]; $workflow->setUserGUID($user_guid); $workflow->setUserName($user_name); } $config->willRunWorkflow($command, $workflow); $workflow->willRunWorkflow(); $err = $workflow->run(); if ($err == 0) { $config->didRunWorkflow($command, $workflow); } exit($err); } catch (ArcanistUsageException $ex) { echo phutil_console_format( "**Usage Exception:** %s\n", $ex->getMessage()); if ($config_trace_mode) { echo "\n"; throw $ex; } exit(1); } catch (Exception $ex) { if ($config_trace_mode) { throw $ex; } echo phutil_console_format( "\n**Exception:**\n%s\n%s\n", $ex->getMessage(), "(Run with --trace for a full exception trace.)"); exit(1); } diff --git a/scripts/phutil_analyzer.php b/scripts/phutil_analyzer.php index 3e89ff3d..c5dc4ea1 100755 --- a/scripts/phutil_analyzer.php +++ b/scripts/phutil_analyzer.php @@ -1,351 +1,356 @@ #!/usr/bin/env php array_fill_keys($builtin_classes, true) + array( 'PhutilBootloader' => true, ), 'function' => array_fill_keys($builtin_functions, true) + array( 'empty' => true, 'isset' => true, 'echo' => true, 'print' => true, 'exit' => true, 'die' => true, ), 'interface' => array_fill_keys($builtin_interfaces, true), ); require_once dirname(__FILE__).'/__init_script__.php'; if ($argc != 2) { $self = basename($argv[0]); echo "usage: {$self} \n"; exit(1); } phutil_require_module('phutil', 'filesystem'); $dir = Filesystem::resolvePath($argv[1]); phutil_require_module('phutil', 'parser/xhpast/bin'); phutil_require_module('phutil', 'parser/xhpast/api/tree'); phutil_require_module('arcanist', 'lint/linter/phutilmodule'); phutil_require_module('arcanist', 'lint/message'); -phutil_require_module('arcanist', 'staticanalysis/parsers/phutilmodule'); +phutil_require_module('arcanist', 'parser/phutilmodule'); $data = array(); $futures = array(); foreach (Filesystem::listDirectory($dir, $hidden_files = false) as $file) { if (!preg_match('/.php$/', $file)) { continue; } $data[$file] = Filesystem::readFile($dir.'/'.$file); $futures[$file] = xhpast_get_parser_future($data[$file]); } $requirements = new PhutilModuleRequirements(); $requirements->addBuiltins($builtin); $has_init = false; $has_files = false; foreach (Futures($futures) as $file => $future) { try { $tree = XHPASTTree::newFromDataAndResolvedExecFuture( $data[$file], $future->resolve()); } catch (XHPASTSyntaxErrorException $ex) { echo "Syntax Error! In '{$file}': ".$ex->getMessage()."\n"; exit(1); } $root = $tree->getRootNode(); $requirements->setCurrentFile($file); if ($file == '__init__.php') { $has_init = true; $calls = $root->selectDescendantsOfType('n_FUNCTION_CALL'); foreach ($calls as $call) { $name = $call->getChildByIndex(0); $call_name = $name->getConcreteString(); if ($call_name == 'phutil_require_source') { $params = $call->getChildByIndex(1)->getChildren(); if (count($params) !== 1) { $requirements->addLint( $call, $call->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_SIGNATURE, "Call to phutil_require_source() must have exactly one argument."); continue; } $param = reset($params); $value = $param->getStringLiteralValue(); if ($value === null) { $requirements->addLint( $param, $param->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_SIGNATURE, "phutil_require_source() parameter must be a string literal."); continue; } $requirements->addSourceDependency($name, $value); } else if ($call_name == 'phutil_require_module') { - analyze_require_module($call, $requirements); + analyze_phutil_require_module($call, $requirements); } } } else { $has_files = true; $requirements->addSourceDeclaration(basename($file)); // Function uses: // - Explicit call // TODO?: String literal in ReflectionFunction(). $calls = $root->selectDescendantsOfType('n_FUNCTION_CALL'); foreach ($calls as $call) { $name = $call->getChildByIndex(0); if ($name->getTypeName() == 'n_VARIABLE' || $name->getTypeName() == 'n_VARIABLE_VARIABLE') { $requirements->addLint( $name, $name->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_DYNAMIC, "Use of variable function calls prevents dependencies from being ". "checked statically. This module may have undetectable errors."); continue; } if ($name->getTypeName() == 'n_CLASS_STATIC_ACCESS') { // We'll pick this up later. continue; } $call_name = $name->getConcreteString(); if ($call_name == 'phutil_require_module') { - analyze_require_module($call, $requirements); + analyze_phutil_require_module($call, $requirements); } else if ($call_name == 'call_user_func' || $call_name == 'call_user_func_array') { $params = $call->getChildByIndex(1)->getChildren(); if (count($params) == 0) { $requirements->addLint( $call, $call->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_SIGNATURE, "Call to {$call_name}() must have at least one argument."); } $symbol = array_shift($params); $symbol_value = $symbol->getStringLiteralValue(); if ($symbol_value) { $requirements->addFunctionDependency( $symbol, $symbol_value); } else { $requirements->addLint( $symbol, $symbol->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_DYNAMIC, "Use of variable arguments to {$call_name} prevents dependencies ". "from being checked statically. This module may have undetectable ". "errors."); } } else { $requirements->addFunctionDependency( $name, $name->getConcreteString()); } } $functions = $root->selectDescendantsOfType('n_FUNCTION_DECLARATION'); foreach ($functions as $function) { $name = $function->getChildByIndex(2); $requirements->addFunctionDeclaration( $name, $name->getConcreteString()); } // Class uses: // - new // - extends (in class declaration) // - Static method call // - Static property access // - Constant use // TODO?: String literal in ReflectionClass(). // TODO?: String literal in array literal in call_user_func / // call_user_func_array(). $classes = $root->selectDescendantsOfType('n_CLASS_DECLARATION'); foreach ($classes as $class) { $class_name = $class->getChildByIndex(1); $requirements->addClassDeclaration( $class_name, $class_name->getConcreteString()); $extends = $class->getChildByIndex(2); foreach ($extends->selectDescendantsOfType('n_CLASS_NAME') as $parent) { $requirements->addClassDependency( $class_name->getConcreteString(), $parent, $parent->getConcreteString()); } $implements = $class->getChildByIndex(3); $interfaces = $implements->selectDescendantsOfType('n_CLASS_NAME'); foreach ($interfaces as $interface) { $requirements->addInterfaceDependency( $class_name->getConcreteString(), $interface, $interface->getConcreteString()); } } if (count($classes) > 1) { foreach ($classes as $class) { $class_name = $class->getChildByIndex(1); $class_string = $class_name->getConcreteString(); $requirements->addLint( $class_name, $class_string, ArcanistPhutilModuleLinter::LINT_ANALYZER_MULTIPLE_CLASSES, "This file declares more than one class. Declare only one class per ". "file."); break; } } $uses_of_new = $root->selectDescendantsOfType('n_NEW'); foreach ($uses_of_new as $new_operator) { $name = $new_operator->getChildByIndex(0); if ($name->getTypeName() == 'n_VARIABLE' || $name->getTypeName() == 'n_VARIABLE_VARIABLE') { $requirements->addLint( $name, $name->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_DYNAMIC, "Use of variable class instantiation prevents dependencies from ". "being checked statically. This module may have undetectable ". "errors."); continue; } $requirements->addClassDependency( null, $name, $name->getConcreteString()); } $static_uses = $root->selectDescendantsOfType('n_CLASS_STATIC_ACCESS'); foreach ($static_uses as $static_use) { $name = $static_use->getChildByIndex(0); if ($name->getTypeName() != 'n_CLASS_NAME') { echo "WARNING UNLINTABLE\n"; continue; } $name_concrete = $name->getConcreteString(); $magic_names = array( 'static' => true, 'parent' => true, 'self' => true, ); if (isset($magic_names[$name_concrete])) { continue; } $requirements->addClassDependency( null, $name, $name_concrete); } // Interface uses: // - implements // - extends (in interface declaration) $interfaces = $root->selectDescendantsOfType('n_INTERFACE_DECLARATION'); foreach ($interfaces as $interface) { $interface_name = $interface->getChildByIndex(1); $requirements->addInterfaceDeclaration( $interface_name, $interface_name->getConcreteString()); $extends = $interface->getChildByIndex(2); foreach ($extends->selectDescendantsOfType('n_CLASS_NAME') as $parent) { $requirements->addInterfaceDependency( $class_name->getConcreteString(), $parent, $parent->getConcreteString()); } } } } if (!$has_init && $has_files) { $requirements->addRawLint( ArcanistPhutilModuleLinter::LINT_ANALYZER_NO_INIT, "Create an __init__.php file in this module."); } echo json_encode($requirements->toDictionary()); -function analyze_require_module( +/** + * Parses meaning from calls to phutil_require_module() in __init__.php files. + * + * @group module + */ +function analyze_phutil_require_module( XHPASTNode $call, PhutilModuleRequirements $requirements) { $name = $call->getChildByIndex(0); $params = $call->getChildByIndex(1)->getChildren(); if (count($params) !== 2) { $requirements->addLint( $call, $call->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_SIGNATURE, "Call to phutil_require_module() must have exactly two arguments."); return; } $module_param = array_pop($params); $library_param = array_pop($params); $library_value = $library_param->getStringLiteralValue(); if ($library_value === null) { $requirements->addLint( $library_param, $library_param->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_SIGNATURE, "phutil_require_module() parameters must be string literals."); return; } $module_value = $module_param->getStringLiteralValue(); if ($module_value === null) { $requirements->addLint( $module_param, $module_param->getConcreteString(), ArcanistPhutilModuleLinter::LINT_ANALYZER_SIGNATURE, "phutil_require_module() parameters must be string literals."); return; } $requirements->addModuleDependency( $name, $library_value.':'.$module_value); } diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index d0d784a4..f9fe6907 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1,121 +1,121 @@ array( 'ArcanistAmendWorkflow' => 'workflow/amend', 'ArcanistApacheLicenseLinter' => 'lint/linter/apachelicense', 'ArcanistApacheLicenseLinterTestCase' => 'lint/linter/apachelicense/__tests__', 'ArcanistBaseUnitTestEngine' => 'unit/engine/base', 'ArcanistBaseWorkflow' => 'workflow/base', 'ArcanistBundle' => 'parser/bundle', 'ArcanistChooseInvalidRevisionException' => 'exception', 'ArcanistChooseNoRevisionsException' => 'exception', 'ArcanistCommitWorkflow' => 'workflow/commit', 'ArcanistConfiguration' => 'configuration', 'ArcanistCoverWorkflow' => 'workflow/cover', 'ArcanistDiffChange' => 'parser/diff/change', 'ArcanistDiffChangeType' => 'parser/diff/changetype', 'ArcanistDiffHunk' => 'parser/diff/hunk', 'ArcanistDiffParser' => 'parser/diff', 'ArcanistDiffParserTestCase' => 'parser/diff/__tests__', 'ArcanistDiffUtils' => 'difference', 'ArcanistDiffWorkflow' => 'workflow/diff', 'ArcanistDifferentialCommitMessage' => 'differential/commitmessage', 'ArcanistDifferentialCommitMessageParserException' => 'differential/commitmessage', 'ArcanistDifferentialRevisionRef' => 'differential/revision', 'ArcanistExportWorkflow' => 'workflow/export', 'ArcanistFilenameLinter' => 'lint/linter/filename', 'ArcanistGeneratedLinter' => 'lint/linter/generated', 'ArcanistGitAPI' => 'repository/api/git', 'ArcanistGitHookPreReceiveWorkflow' => 'workflow/git-hook-pre-receive', 'ArcanistHelpWorkflow' => 'workflow/help', 'ArcanistLicenseLinter' => 'lint/linter/license', 'ArcanistLintEngine' => 'lint/engine/base', 'ArcanistLintMessage' => 'lint/message', 'ArcanistLintPatcher' => 'lint/patcher', 'ArcanistLintRenderer' => 'lint/renderer', 'ArcanistLintResult' => 'lint/result', 'ArcanistLintSeverity' => 'lint/severity', 'ArcanistLintWorkflow' => 'workflow/lint', 'ArcanistLinter' => 'lint/linter/base', 'ArcanistLinterTestCase' => 'lint/linter/base/test', 'ArcanistListWorkflow' => 'workflow/list', 'ArcanistMarkCommittedWorkflow' => 'workflow/mark-committed', 'ArcanistNoEffectException' => 'exception/usage/noeffect', 'ArcanistNoEngineException' => 'exception/usage/noengine', 'ArcanistPEP8Linter' => 'lint/linter/pep8', 'ArcanistPatchWorkflow' => 'workflow/patch', 'ArcanistPhutilModuleLinter' => 'lint/linter/phutilmodule', 'ArcanistPhutilTestCase' => 'unit/engine/phutil/testcase', 'ArcanistPhutilTestTerminatedException' => 'unit/engine/phutil/testcase/exception', 'ArcanistRepositoryAPI' => 'repository/api/base', 'ArcanistShellCompleteWorkflow' => 'workflow/shell-complete', 'ArcanistSubversionAPI' => 'repository/api/subversion', 'ArcanistSvnHookPreCommitWorkflow' => 'workflow/svn-hook-pre-commit', 'ArcanistTextLinter' => 'lint/linter/text', 'ArcanistTextLinterTestCase' => 'lint/linter/text/__tests__', 'ArcanistUnitTestResult' => 'unit/result', 'ArcanistUnitWorkflow' => 'workflow/unit', 'ArcanistUsageException' => 'exception/usage', 'ArcanistUserAbortException' => 'exception/usage/userabort', 'ArcanistWorkingCopyIdentity' => 'workingcopyidentity', 'ArcanistXHPASTLinter' => 'lint/linter/xhpast', 'ArcanistXHPASTLinterTestCase' => 'lint/linter/xhpast/__tests__', 'PhutilLintEngine' => 'lint/engine/phutil', - 'PhutilModuleRequirements' => 'staticanalysis/parsers/phutilmodule', + 'PhutilModuleRequirements' => 'parser/phutilmodule', 'PhutilUnitTestEngine' => 'unit/engine/phutil', 'PhutilUnitTestEngineTestCase' => 'unit/engine/phutil/__tests__', 'UnitTestableArcanistLintEngine' => 'lint/engine/test', ), 'function' => array( ), 'requires_class' => array( 'ArcanistAmendWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistApacheLicenseLinter' => 'ArcanistLicenseLinter', 'ArcanistApacheLicenseLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistCommitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCoverWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistDiffParserTestCase' => 'ArcanistPhutilTestCase', 'ArcanistDiffWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistExportWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistFilenameLinter' => 'ArcanistLinter', 'ArcanistGeneratedLinter' => 'ArcanistLinter', 'ArcanistGitAPI' => 'ArcanistRepositoryAPI', 'ArcanistGitHookPreReceiveWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistHelpWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistLicenseLinter' => 'ArcanistLinter', 'ArcanistLintWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistLinterTestCase' => 'ArcanistPhutilTestCase', 'ArcanistListWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistMarkCommittedWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistNoEffectException' => 'ArcanistUsageException', 'ArcanistNoEngineException' => 'ArcanistUsageException', 'ArcanistPEP8Linter' => 'ArcanistLinter', 'ArcanistPatchWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistPhutilModuleLinter' => 'ArcanistLinter', 'ArcanistShellCompleteWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistSubversionAPI' => 'ArcanistRepositoryAPI', 'ArcanistSvnHookPreCommitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistTextLinter' => 'ArcanistLinter', 'ArcanistTextLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistUnitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistUserAbortException' => 'ArcanistUsageException', 'ArcanistXHPASTLinter' => 'ArcanistLinter', 'ArcanistXHPASTLinterTestCase' => 'ArcanistLinterTestCase', 'PhutilLintEngine' => 'ArcanistLintEngine', 'PhutilUnitTestEngine' => 'ArcanistBaseUnitTestEngine', 'PhutilUnitTestEngineTestCase' => 'ArcanistPhutilTestCase', 'UnitTestableArcanistLintEngine' => 'ArcanistLintEngine', ), 'requires_interface' => array( ), )); diff --git a/src/configuration/ArcanistConfiguration.php b/src/configuration/ArcanistConfiguration.php index 1f5c2389..64cb22cc 100644 --- a/src/configuration/ArcanistConfiguration.php +++ b/src/configuration/ArcanistConfiguration.php @@ -1,100 +1,122 @@ setType('class') ->setName($workflow_class) ->setLibrary('arcanist') ->selectAndLoadSymbols(); if (!$symbols) { return null; } return newv($workflow_class, array()); } public function buildAllWorkflows() { $symbols = id(new PhutilSymbolLoader()) ->setType('class') ->setAncestorClass('ArcanistBaseWorkflow') ->setLibrary('arcanist') ->selectAndLoadSymbols(); $workflows = array(); foreach ($symbols as $symbol) { $class = $symbol['name']; $name = preg_replace('/^Arcanist(\w+)Workflow$/', '\1', $class); $name[0] = strtolower($name[0]); $name = preg_replace_callback( '/[A-Z]/', array( 'ArcanistConfiguration', 'replaceClassnameUppers', ), $name); $name = strtolower($name); $workflows[$name] = newv($class, array()); } return $workflows; } public function willRunWorkflow($command, ArcanistBaseWorkflow $workflow) { // This is a hook. } public function didRunWorkflow($command, ArcanistBaseWorkflow $workflow) { // This is a hook. } public function getCustomArgumentsForCommand($command) { return array(); } public static function replaceClassnameHyphens($m) { return strtoupper($m[1]); } public static function replaceClassnameUppers($m) { return '-'.strtolower($m[0]); } } diff --git a/src/difference/ArcanistDiffUtils.php b/src/difference/ArcanistDiffUtils.php index 9ff4f040..e1534cfb 100644 --- a/src/difference/ArcanistDiffUtils.php +++ b/src/difference/ArcanistDiffUtils.php @@ -1,313 +1,318 @@ '; $highlight_c = ''; $n = strlen($str); for ($i = 0; $i < $n; $i++) { if ($p == $e) { do { if (empty($intra_stack)) { $buf .= substr($str, $i); break 2; } $stack = array_shift($intra_stack); $s = $e; $e += $stack[1]; } while ($stack[0] == 0); } if (!$highlight && !$tag && !$ent && $p == $s) { $buf .= $highlight_o; $highlight = true; } if ($str[$i] == '<') { $tag = true; if ($highlight) { $buf .= $highlight_c; } } if (!$tag) { if ($str[$i] == '&') { $ent = true; } if ($ent && $str[$i] == ';') { $ent = false; } if (!$ent) { $p++; } } $buf .= $str[$i]; if ($tag && $str[$i] == '>') { $tag = false; if ($highlight) { $buf .= $highlight_o; } } if ($highlight && ($p == $e || $i == $n - 1)) { $buf .= $highlight_c; $highlight = false; } } return $buf; } private static function collapseIntralineRuns($runs) { $count = count($runs); for ($ii = 0; $ii < $count - 1; $ii++) { if ($runs[$ii][0] == $runs[$ii + 1][0]) { $runs[$ii + 1][1] += $runs[$ii][1]; unset($runs[$ii]); } } return array_values($runs); } private static function buildLevenshteinDifferenceString($o, $n) { $olt = strlen($o); $nlt = strlen($n); if (!$olt) { return str_repeat('i', $nlt); } if (!$nlt) { return str_repeat('d', $olt); } $min = min($olt, $nlt); $t_start = microtime(true); $pre = 0; while ($pre < $min && $o[$pre] == $n[$pre]) { $pre++; } $end = 0; while ($end < $min && $o[($olt - 1) - $end] == $n[($nlt - 1) - $end]) { $end++; } if ($end + $pre >= $min) { $end = min($end, $min - $pre); $prefix = str_repeat('s', $pre); $suffix = str_repeat('s', $end); $infix = null; if ($olt > $nlt) { $infix = str_repeat('d', $olt - ($end + $pre)); } else if ($nlt > $olt) { $infix = str_repeat('i', $nlt - ($end + $pre)); } return $prefix.$infix.$suffix; } if ($min - ($end + $pre) > 80) { $max = max($olt, $nlt); return str_repeat('x', $min) . str_repeat($olt < $nlt ? 'i' : 'd', $max - $min); } $prefix = str_repeat('s', $pre); $suffix = str_repeat('s', $end); $o = substr($o, $pre, $olt - $end - $pre); $n = substr($n, $pre, $nlt - $end - $pre); $ol = strlen($o); $nl = strlen($n); $m = array_fill(0, strlen($o) + 1, array_fill(0, strlen($n) + 1, array())); $T_D = 'd'; $T_I = 'i'; $T_S = 's'; $T_X = 'x'; $path = 0; for ($ii = 0; $ii <= $ol; $ii++) { $m[$ii][0][0] = $ii; $m[$ii][0][1] = ($path += $ii); $m[$ii][0][2] = $T_D; } $path = 0; for ($jj = 0; $jj <= $nl; $jj++) { $m[0][$jj][0] = $jj; $m[0][$jj][1] = ($path += $jj); $m[0][$jj][2] = $T_I; } $ii = 1; do { $jj = 1; do { if ($o[$ii - 1] == $n[$jj - 1]) { $sub_t_cost = $m[$ii - 1][$jj - 1][0] + 0; $sub_t = $T_S; } else { $sub_t_cost = $m[$ii - 1][$jj - 1][0] + 2; $sub_t = $T_X; } $sub_p_cost = $m[$ii - 1][$jj - 1][1] + $sub_t_cost; $del_t_cost = $m[$ii - 1][$jj][0] + 1; $del_p_cost = $m[$ii - 1][$jj][1] + $del_t_cost; $ins_t_cost = $m[$ii][$jj - 1][0] + 1; $ins_p_cost = $m[$ii][$jj - 1][1] + $ins_t_cost; if ($sub_p_cost <= $del_p_cost && $sub_p_cost <= $ins_p_cost) { $m[$ii][$jj] = array( $sub_t_cost, $sub_p_cost, $sub_t); } else if ($ins_p_cost <= $del_p_cost) { $m[$ii][$jj] = array( $ins_t_cost, $ins_p_cost, $T_I); } else { $m[$ii][$jj] = array( $del_t_cost, $del_p_cost, $T_D); } } while ($jj++ < $nl); } while ($ii++ < $ol); $result = ''; $ii = $ol; $jj = $nl; do { $r = $m[$ii][$jj][2]; $result .= $r; switch ($r) { case 's': case 'x': $ii--; $jj--; break; case 'i': $jj--; break; case 'd': $ii--; break; } } while ($ii || $jj); return $prefix.strrev($result).$suffix; } } diff --git a/src/differential/commitmessage/ArcanistDifferentialCommitMessage.php b/src/differential/commitmessage/ArcanistDifferentialCommitMessage.php index 728397ca..3b0328ef 100644 --- a/src/differential/commitmessage/ArcanistDifferentialCommitMessage.php +++ b/src/differential/commitmessage/ArcanistDifferentialCommitMessage.php @@ -1,94 +1,99 @@ rawCorpus = $corpus; // TODO: Remove "Diffcamp" backward compatibility. $match = null; if (preg_match('/^(?:Differential|DiffCamp) Revision:\s*D?(\d+)/im', $corpus, $match)) { $obj->revisionID = (int)$match[1]; } $pattern = '/^git-svn-id:\s*([^@]+)@(\d+)\s+(.*)$/m'; if (preg_match($pattern, $corpus, $match)) { $obj->gitSVNBaseRevision = $match[1].'@'.$match[2]; $obj->gitSVNBasePath = $match[1]; $obj->gitSVNUUID = $match[3]; } return $obj; } public function getRawCorpus() { return $this->rawCorpus; } public function getRevisionID() { return $this->revisionID; } public function pullDataFromConduit(ConduitClient $conduit) { $result = $conduit->callMethod( 'differential.parsecommitmessage', array( 'corpus' => $this->rawCorpus, )); $result = $result->resolve(); if (!empty($result['error'])) { throw new ArcanistDifferentialCommitMessageParserException( $result['error']); } $this->fields = $result['fields']; } public function getFieldValue($key) { if (array_key_exists($key, $this->fields)) { return $this->fields[$key]; } return null; } public function getFields() { return $this->fields; } public function getGitSVNBaseRevision() { return $this->gitSVNBaseRevision; } public function getGitSVNBasePath() { return $this->gitSVNBasePath; } public function getGitSVNUUID() { return $this->gitSVNUUID; } } diff --git a/src/differential/commitmessage/ArcanistDifferentialCommitMessageParserException.php b/src/differential/commitmessage/ArcanistDifferentialCommitMessageParserException.php index 37122995..41ea5cd2 100644 --- a/src/differential/commitmessage/ArcanistDifferentialCommitMessageParserException.php +++ b/src/differential/commitmessage/ArcanistDifferentialCommitMessageParserException.php @@ -1,21 +1,26 @@ id = $dictionary['id']; $ref->name = $dictionary['name']; $ref->statusName = $dictionary['statusName']; $ref->sourcePath = $dictionary['sourcePath']; return $ref; } protected function __construct() { } public function getID() { return $this->id; } public function getName() { return $this->name; } public function getStatusName() { return $this->statusName; } public function getSourcePath() { return $this->sourcePath; } } diff --git a/src/docs/arcconfig.diviner b/src/docs/arcconfig.diviner index c7085e4c..0ae197e2 100644 --- a/src/docs/arcconfig.diviner +++ b/src/docs/arcconfig.diviner @@ -1,53 +1,52 @@ @title Setting Up .arcconfig @group config -This document describes how to configure Arcanist projects with ##.arcconfig## -files. +Explains how to configure Arcanist projects with ##.arcconfig## files. = .arcconfig Basics = Arcanist uses ##.arcconfig## files to determine a number of things about project configuration. For instance, these are things it figures out from ##.arcconfig##: - where the logical root directory of a project is; - which server Arcanist should send diffs to for code review; and - which lint rules should be applied. An ##.arcconfig## file is a JSON file which you check into your project's root. A simple, valid file looks something like this: { "project_id" : "some_project_name", "conduit_uri" : "https://phabricator.example.com/api/" } Here's what these options mean: - **project_id**: a human-readable string identifying the project - **conduit_uri**: the Conduit API URI for the Phabricator installation that Arcanist should send diffs to for review. Generally, if you access Phabricator at ##https://phabricator.example.com/##, the **conduit_uri** is ##https://phabricator.example.com/api/##. Be mindful about "http" vs "https". For an exhaustive list of available options, see below. = Advanced .arcconfig = Other options include: - **lint_engine**: the name of a subclass of @{class:ArcanistLintEngine}, which should be used to apply lint rules to this project. See (TODO). - **unit_engine**: the name of a subclass of @{class:ArcanistBaseUnitTestEngine.php}, which should be used to apply unit test rules to this project. See (TODO). - **arcanist_configuration**: the name of a subclass of @{class:ArcanistConfiguration} which can add new command flags for this project or provide entirely new commands. - **remote_hooks_installed**: tells Arcanist that you've set up remote hooks in the master repository (see @{article:Installing Arcanist SVN Hooks} for SVN, or (TODO) for git). - **copyright_holder**: used by @{class:ArcanistLicenseLinter} to apply license notices to source files. - **phutil_libraries**: map of additional Phutil libraries to load at startup. diff --git a/src/docs/building_new_configuration_classes.diviner b/src/docs/building_new_configuration_classes.diviner new file mode 100644 index 00000000..6fb31617 --- /dev/null +++ b/src/docs/building_new_configuration_classes.diviner @@ -0,0 +1,83 @@ +@title Building New Configuration Classes +@group config + +Explains how to build new classes to control how Arcanist behaves. + += Overview = + +Arcanist has some basic configuration options available in the ##.arcconfig## +file (see @{article:Setting Up .arcconfig}), but it can't handle everything. If +you want to customize Arcanist at a deeper level, you need to build new classes. +For instance: + + - if you want to configure linters, or add new linters, you need to create a + new class which extends @{class:ArcanistLintEngine}. + - if you want to integrate with a unit testing framework, you need to create a + new class which extends @{class:ArcanistBaseUnitTestEngine}. + - if you you want to change how workflows behave, or add new workflows, you + need to create a new class which extends @{class:ArcanistConfiguration}. + +Arcanist works through a sort of dependency-injection approach. For example, +Arcanist does not run lint rules by default, but you can set **lint_engine** +in your ##.arcconfig## to the name of a class which extends +@{class:ArcanistLintEngine}. When running from inside your project, Arcanist +will load this class and call methods on it in order to run lint. To make this +work, you need to do three things: + + - actually write the class; + - add the library where the class exists to your ##.arcconfig##; + - add the class name to your ##.arcconfig## as the **lint_engine**, + **unit_engine**, or **arcanist_configuration**. + += Write the Class = + +(TODO) + += Load the Class = + +To make the class loadable, you need to put the path to it in your +##.arcconfig##, under **phutil_libraries**: + + { + // ... + "phutil_libraries" : { + // ... + "my-library" : "/path/to/my/library", + // ... + } + // ... + } + +You can either specify an absolute path, or a path relative to the project root. +When you run ##arc --trace##, you should see a message to the effect that it has +loaded your library. + +For debugging or testing, you can also run Arcanist with the +##--load-phutil-library## flag: + + arc --load-phutil-library=/path/to/library + +You can specify this flag more than once to load several libraries. Note that +if you use this flag, Arcanist will ignore any libraries listed in +##.arcconfig##. + += Use the Class = + +This step is easy: just edit ##.arcconfig## to specify your class name as +the appropriate configuration value. + + { + // ... + "lint_engine" : "MyCustomArcanistLintEngine", + // ... + } + +Now, when you run Arcanist in your project, it will invoke your class when +appropriate. + +For lint and unit tests, you can also use the ##--engine## flag override the +default engine: + + arc lint --engine MyCustomArcanistLintEngine + +This is mostly useful for debugging and testing. diff --git a/src/docs/overview.diviner b/src/docs/overview.diviner index 0bc22578..f785ad97 100644 --- a/src/docs/overview.diviner +++ b/src/docs/overview.diviner @@ -1,31 +1,31 @@ @title Arcanist Overview @group intro -This document provides an overview of Arcanist, a code workflow tool. Arcanist -(commonly, "arc") is the command-line frontend to Differential. +Overview of Arcanist, a code workflow tool. -A detailed command reference is available by running ##arc help##. +Arcanist (commonly, "arc") is the command-line frontend to Differential. A +detailed command reference is available by running ##arc help##. = Overview = Arcanist is the command-line interface to Differential, and supports some related revision control operations. Arcanist allows you to do things like: - send your code to Differential for review with ##arc diff## - commit reviewed changes with ##arc commit## (svn) or ##arc amend## (git) - check your code for syntax and style errors with ##arc lint## - run unit tests that cover your changes with ##arc unit## - export changes from Differential or the working copy with ##arc export## - apply patches from Differential or patchfiles with ##arc patch## - execute context-aware blame with ##arc cover## - show Differential status with ##arc list## In general, these workflows are agnostic to the underlying version control system and will work properly in git or svn repositories. = Configuring a New Project = Create a .arcconfig file. = SVN Basics = diff --git a/src/docs/svn_hooks.diviner b/src/docs/svn_hooks.diviner index 3605299f..27c4d908 100644 --- a/src/docs/svn_hooks.diviner +++ b/src/docs/svn_hooks.diviner @@ -1,35 +1,37 @@ @title Installing Arcanist SVN Hooks @group config +Describes how to set up Arcanist as an SVN pre-commit hook. + = Installing Arcanist SVN Hooks = You can install Arcanist as an SVN pre-commit hook, to reject commits which contain lint errors. The immediate value of this is that syntax errors won't be committable, but you can block other kinds of badness with appropriate lint engines. To install Arcanist as a pre-commit hook, add this to your svn/hooks/pre-commit: #!/bin/sh /usr/local/bin/php -f /path/to/arcanist/bin/arc svn-hook-pre-commit $@ 1>&2 Make sure you make this file executable, or you'll get an error for every commit with an unhelpful error message. You also need to specify the full path to PHP since SVN nukes ENV before executing scripts. Alternatively you can specify PATH explicitly. If your project is configured to run linters or lint engines which aren't part of Arcanist, specify where to load them from with ##--load-phutil-library##: --load-phutil-library=/path/to/library/root Since SVN commit hooks run without access to a working copy, you'll need to keep one checked out somewhere and reference it with ##--load-phutil-library## if you build new linters or customize lint engines. For example, your hook might look like this: #!/bin/sh /usr/local/bin/php -f /path/to/arcanist/bin/arc svn-hook-pre-commit \ --load-phutil-library=/path/to/custom/lint/engine \ --load-phutil-library=/path/to/custom/unittest/engine \ $@ 1>&2 diff --git a/src/exception/ArcanistChooseInvalidRevisionException.php b/src/exception/ArcanistChooseInvalidRevisionException.php index 85664e25..f49558d3 100644 --- a/src/exception/ArcanistChooseInvalidRevisionException.php +++ b/src/exception/ArcanistChooseInvalidRevisionException.php @@ -1,21 +1,26 @@ workingCopy = $working_copy; return $this; } public function getWorkingCopy() { return $this->workingCopy; } public function setPaths($paths) { $this->paths = $paths; return $this; } public function getPaths() { return $this->paths; } public function setPathChangedLines($path, array $changed) { $this->changedLines[$path] = array_fill_keys($changed, true); return $this; } public function getPathChangedLines($path) { return idx($this->changedLines, $path); } public function setFileData($data) { $this->fileData = $data + $this->fileData; return $this; } public function setCommitHookMode($mode) { $this->commitHookMode = $mode; return $this; } protected function loadData($path) { if (!isset($this->fileData[$path])) { $disk_path = $this->getFilePathOnDisk($path); $this->fileData[$path] = Filesystem::readFile($disk_path); } return $this->fileData[$path]; } public function pathExists($path) { if ($this->getCommitHookMode()) { return (idx($this->fileData, $path) !== null); } else { $disk_path = $this->getFilePathOnDisk($path); return Filesystem::pathExists($disk_path); } } public function getFilePathOnDisk($path) { return Filesystem::resolvePath( $path, $this->getWorkingCopy()->getProjectRoot()); } public function setMinimumSeverity($severity) { $this->minimumSeverity = $severity; return $this; } public function getCommitHookMode() { return $this->commitHookMode; } public function run() { $stopped = array(); $linters = $this->buildLinters(); if (!$linters) { throw new ArcanistNoEffectException("No linters to run."); } $have_paths = false; foreach ($linters as $linter) { if ($linter->getPaths()) { $have_paths = true; break; } } if (!$have_paths) { throw new ArcanistNoEffectException("No paths are lintable."); } foreach ($linters as $linter) { $linter->setEngine($this); $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($stopped[$path])) { unset($paths[$key]); } } $paths = array_values($paths); if ($paths) { $linter->willLintPaths($paths); foreach ($paths as $path) { $linter->willLintPath($path); $linter->lintPath($path); if ($linter->didStopAllLinters()) { $stopped[$path] = true; } } } $minimum = $this->minimumSeverity; foreach ($linter->getLintMessages() as $message) { if (!ArcanistLintSeverity::isAtLeastAsSevere($message, $minimum)) { continue; } // When a user runs "arc diff", we default to raising only warnings on // lines they have changed (errors are still raised anywhere in the // file). $changed = $this->getPathChangedLines($message->getPath()); if ($changed !== null && !$message->isError()) { if (empty($changed[$message->getLine()])) { continue; } } $result = $this->getResultForPath($message->getPath()); $result->addMessage($message); } } foreach ($this->results as $path => $result) { $result->setFilePathOnDisk($this->getFilePathOnDisk($path)); if (isset($this->fileData[$path])) { // Only set the data if any linter loaded it. The goal here is to // avoid binaries when we don't actually care about their contents, // for performance. $result->setData($this->fileData[$path]); } } return $this->results; } abstract protected function buildLinters(); private function getResultForPath($path) { if (empty($this->results[$path])) { $result = new ArcanistLintResult(); $result->setPath($path); $this->results[$path] = $result; } return $this->results[$path]; } 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); } } diff --git a/src/lint/engine/phutil/PhutilLintEngine.php b/src/lint/engine/phutil/PhutilLintEngine.php index d393c8c9..d923c166 100644 --- a/src/lint/engine/phutil/PhutilLintEngine.php +++ b/src/lint/engine/phutil/PhutilLintEngine.php @@ -1,96 +1,101 @@ getPaths(); // This needs to go first so that changes to generated files cause module // linting. This linter also operates on removed files, because removing // a file changes the static properties of a module. $module_linter = new ArcanistPhutilModuleLinter(); $linters[] = $module_linter; foreach ($paths as $path) { $module_linter->addPath($path); } // Remaining lint engines operate on file contents and ignore removed // files. foreach ($paths as $key => $path) { if (!$this->pathExists($path)) { unset($paths[$key]); } if (preg_match('@^externals/@', $path)) { // Third-party stuff lives in /externals/; don't run lint engines // against it. unset($paths[$key]); } } $generated_linter = new ArcanistGeneratedLinter(); $linters[] = $generated_linter; $text_linter = new ArcanistTextLinter(); $linters[] = $text_linter; foreach ($paths as $path) { $is_text = false; if (preg_match('/\.(php|css|js)$/', $path)) { $is_text = true; } if ($is_text) { $generated_linter->addPath($path); $generated_linter->addData($path, $this->loadData($path)); $text_linter->addPath($path); $text_linter->addData($path, $this->loadData($path)); } } $name_linter = new ArcanistFilenameLinter(); $linters[] = $name_linter; foreach ($paths as $path) { $name_linter->addPath($path); } $xhpast_linter = new ArcanistXHPASTLinter(); $license_linter = new ArcanistApacheLicenseLinter(); $linters[] = $xhpast_linter; $linters[] = $license_linter; foreach ($paths as $path) { if (preg_match('/\.php$/', $path)) { $xhpast_linter->addPath($path); $xhpast_linter->addData($path, $this->loadData($path)); } } foreach ($paths as $path) { if (preg_match('/\.(php|cpp|hpp|l|y)$/', $path)) { if (!preg_match('@^externals/@', $path)) { $license_linter->addPath($path); $license_linter->addData($path, $this->loadData($path)); } } } return $linters; } } diff --git a/src/lint/engine/test/UnitTestableArcanistLintEngine.php b/src/lint/engine/test/UnitTestableArcanistLintEngine.php index 84246d8b..cbb71bec 100644 --- a/src/lint/engine/test/UnitTestableArcanistLintEngine.php +++ b/src/lint/engine/test/UnitTestableArcanistLintEngine.php @@ -1,37 +1,43 @@ linters[] = $linter; return $this; } public function addFileData($path, $data) { $this->fileData[$path] = $data; return $this; } protected function buildLinters() { return $this->linters; } } diff --git a/src/lint/linter/apachelicense/ArcanistApacheLicenseLinter.php b/src/lint/linter/apachelicense/ArcanistApacheLicenseLinter.php index 3029b3b4..f150572a 100644 --- a/src/lint/linter/apachelicense/ArcanistApacheLicenseLinter.php +++ b/src/lint/linter/apachelicense/ArcanistApacheLicenseLinter.php @@ -1,64 +1,69 @@ executeTestsInDirectory( dirname(__FILE__).'/data/', $linter, $working_copy); } } diff --git a/src/lint/linter/base/ArcanistLinter.php b/src/lint/linter/base/ArcanistLinter.php index 4474418b..b704c41d 100644 --- a/src/lint/linter/base/ArcanistLinter.php +++ b/src/lint/linter/base/ArcanistLinter.php @@ -1,185 +1,190 @@ customSeverityMap = $map; return $this; } public function getActivePath() { return $this->activePath; } public function stopAllLinters() { $this->stopAllLinters = true; return $this; } public function didStopAllLinters() { return $this->stopAllLinters; } public function addPath($path) { $this->paths[$path] = $path; return $this; } public function getPaths() { return array_values($this->paths); } public function addData($path, $data) { $this->data[$path] = $data; return $this; } protected function getData($path) { if (!array_key_exists($path, $this->data)) { throw new Exception("Data is not provided for path '{$path}'!"); } return $this->data[$path]; } public function setEngine($engine) { $this->engine = $engine; return $this; } protected function getEngine() { return $this->engine; } public function getLintMessageFullCode($short_code) { return $this->getLinterName().$short_code; } public function getLintMessageSeverity($code) { $map = $this->customSeverityMap; if (isset($map[$code])) { return $map[$code]; } $map = $this->getLintSeverityMap(); if (isset($map[$code])) { return $map[$code]; } return ArcanistLintSeverity::SEVERITY_ERROR; } public function getLintMessageName($code) { $map = $this->getLintNameMap(); if (isset($map[$code])) { return $map[$code]; } return "Unknown lint message!"; } protected function addLintMessage(ArcanistLintMessage $message) { $this->messages[] = $message; return $message; } public function getLintMessages() { return $this->messages; } protected function raiseLintAtLine( $line, $char, $code, $desc, $original = null, $replacement = null) { $dict = array( 'path' => $this->getActivePath(), 'line' => $line, 'char' => $char, 'code' => $this->getLintMessageFullCode($code), 'severity' => $this->getLintMessageSeverity($code), 'name' => $this->getLintMessageName($code), 'description' => $desc, ); if ($original !== null) { $dict['original'] = $original; } if ($replacement !== null) { $dict['replacement'] = $replacement; } return $this->addLintMessage(ArcanistLintMessage::newFromDictionary($dict)); } protected function raiseLintAtPath( $code, $desc) { return $this->raiseLintAtLine(null, null, $code, $desc, null, null); } protected function raiseLintAtOffset( $offset, $code, $desc, $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, $desc, $original, $replacement); } public function willLintPath($path) { $this->stopAllLinters = false; $this->activePath = $path; } abstract public function willLintPaths(array $paths); abstract public function lintPath($path); abstract public function getLinterName(); abstract public function getLintSeverityMap(); abstract public function getLintNameMap(); } diff --git a/src/lint/linter/base/test/ArcanistLinterTestCase.php b/src/lint/linter/base/test/ArcanistLinterTestCase.php index 8b02bdf1..0a4bff4a 100644 --- a/src/lint/linter/base/test/ArcanistLinterTestCase.php +++ b/src/lint/linter/base/test/ArcanistLinterTestCase.php @@ -1,169 +1,174 @@ lintFile($root.$file, $linter, $working_copy); } } private function lintFile($file, $linter, $working_copy) { $linter = clone $linter; $contents = Filesystem::readFile($file); $contents = explode("~~~~~~~~~~\n", $contents); if (count($contents) < 2) { throw new Exception( "Expected '~~~~~~~~~~' separating test case and results."); } list ($data, $expect, $xform, $config) = array_merge( $contents, array(null, null)); $basename = basename($file); if ($config) { $config = json_decode($config, true); if (!is_array($config)) { throw new Exception( "Invalid configuration in test '{$basename}', not valid JSON."); } } else { $config = array(); } /* TODO: ? validate_parameter_list( $config, array( ), array( 'project' => true, 'path' => true, 'hook' => true, )); */ $exception = null; $after_lint = null; $messages = null; $exception_message = false; $caught_exception = false; try { $path = idx($config, 'path', 'lint/'.$basename.'.php'); $engine = new UnitTestableArcanistLintEngine(); $engine->setWorkingCopy($working_copy); $engine->setPaths(array($path)); $engine->setCommitHookMode(idx($config, 'hook', false)); $linter->addPath($path); $linter->addData($path, $data); $engine->addLinter($linter); $engine->addFileData($path, $data); $results = $engine->run(); $this->assertEqual( 1, count($results), 'Expect one result returned by linter.'); $result = reset($results); $patcher = ArcanistLintPatcher::newFromArcanistLintResult($result); $after_lint = $patcher->getModifiedFileContent(); } catch (ArcanistPhutilTestTerminatedException $ex) { throw $ex; } catch (Exception $exception) { $caught_exception = true; $exception_message = $exception->getMessage()."\n\n". $exception->getTraceAsString(); } switch ($basename) { default: $this->assertEqual(false, $caught_exception, $exception_message); $this->compareLint($basename, $expect, $result); $this->compareTransform($xform, $after_lint); break; } } private function compareLint($file, $expect, $result) { $seen = array(); $raised = array(); foreach ($result->getMessages() as $message) { $sev = $message->getSeverity(); $line = $message->getLine(); $char = $message->getChar(); $code = $message->getCode(); $name = $message->getName(); $seen[] = $sev.":".$line.":".$char; $raised[] = " {$sev} at line {$line}, char {$char}: {$code} {$name}"; } $expect = trim($expect); if ($expect) { $expect = explode("\n", $expect); } else { $expect = array(); } foreach ($expect as $key => $expected) { $expect[$key] = reset(explode(' ', $expected)); } $expect = array_fill_keys($expect, true); $seen = array_fill_keys($seen, true); if (!$raised) { $raised = array("No messages."); } $raised = "Actually raised:\n".implode("\n", $raised); foreach (array_diff_key($expect, $seen) as $missing => $ignored) { list($sev, $line, $char) = explode(':', $missing); $this->assertFailure( "In '{$file}', ". "expected lint to raise {$sev} on line {$line} at char {$char}, ". "but no {$sev} was raised. {$raised}"); } foreach (array_diff_key($seen, $expect) as $surprising => $ignored) { list($sev, $line, $char) = explode(':', $surprising); $this->assertFailure( "In '{$file}', ". "lint raised {$sev} on line {$line} at char {$char}, ". "but nothing was expected. {$raised}"); } } private function compareTransform($expected, $actual) { if (!strlen($expected)) { return; } $this->assertEqual( $expected, $actual, "File as patched by lint did not match the expected patched file."); } } diff --git a/src/lint/linter/filename/ArcanistFilenameLinter.php b/src/lint/linter/filename/ArcanistFilenameLinter.php index bd5659c6..f5630c03 100644 --- a/src/lint/linter/filename/ArcanistFilenameLinter.php +++ b/src/lint/linter/filename/ArcanistFilenameLinter.php @@ -1,50 +1,55 @@ 'Bad Filename', ); } public function lintPath($path) { if (!preg_match('@^[a-z0-9./_-]+$@i', $path)) { $this->raiseLintAtPath( self::LINT_BAD_FILENAME, 'Name files using only letters, numbers, period, hyphen and '. 'underscore.'); } } } diff --git a/src/lint/linter/generated/ArcanistGeneratedLinter.php b/src/lint/linter/generated/ArcanistGeneratedLinter.php index 15116302..db739091 100644 --- a/src/lint/linter/generated/ArcanistGeneratedLinter.php +++ b/src/lint/linter/generated/ArcanistGeneratedLinter.php @@ -1,34 +1,35 @@ getData($path); if (preg_match('/@generated/', $data)) { $this->stopAllLinters(); } } } diff --git a/src/lint/linter/license/ArcanistLicenseLinter.php b/src/lint/linter/license/ArcanistLicenseLinter.php index 35ea12ca..819cb731 100644 --- a/src/lint/linter/license/ArcanistLicenseLinter.php +++ b/src/lint/linter/license/ArcanistLicenseLinter.php @@ -1,79 +1,84 @@ 'No License Header', ); } /** * Given the name of the copyright holder, return appropriate license header * text. */ abstract protected function getLicenseText($copyright_holder); /** * Return an array of regular expressions that, if matched, indicate * that a copyright header is required. The appropriate match will be * stripped from the input when comparing against the expected license. */ abstract protected function getLicensePatterns(); public function lintPath($path) { $working_copy = $this->getEngine()->getWorkingCopy(); $copyright_holder = $working_copy->getConfig('copyright_holder'); if (!$copyright_holder) { return; } $patterns = $this->getLicensePatterns(); $license = $this->getLicenseText($copyright_holder); $data = $this->getData($path); $matches = 0; foreach ($patterns as $pattern) { if (preg_match($pattern, $data, $matches)) { $expect = rtrim(implode('', array_slice($matches, 1)))."\n".$license; if (trim($matches[0]) != trim($expect)) { $this->raiseLintAtOffset( 0, self::LINT_NO_LICENSE_HEADER, 'This file has a missing or out of date license header.', $matches[0], ltrim($expect)); } break; } } } } diff --git a/src/lint/linter/pep8/ArcanistPEP8Linter.php b/src/lint/linter/pep8/ArcanistPEP8Linter.php index 3af1773f..2f6b3415 100644 --- a/src/lint/linter/pep8/ArcanistPEP8Linter.php +++ b/src/lint/linter/pep8/ArcanistPEP8Linter.php @@ -1,78 +1,83 @@ getPEP8Options(); list($stdout) = execx( "/usr/bin/env python2.6 %s {$options} %s", $pep8_bin, $this->getEngine()->getFilePathOnDisk($path)); $lines = explode("\n", $stdout); $messages = array(); foreach ($lines as $line) { $matches = null; if (!preg_match('/^(.*?):(\d+):(\d+): (\S+) (.*)$/', $line, $matches)) { continue; } foreach ($matches as $key => $match) { $matches[$key] = trim($match); } $message = new ArcanistLintMessage(); $message->setPath($path); $message->setLine($matches[2]); $message->setChar($matches[3]); $message->setCode($matches[4]); $message->setName('PEP8 '.$matches[4]); $message->setDescription($matches[5]); if ($matches[4][0] == 'E') { $message->setSeverity(ArcanistLintSeverity::SEVERITY_ERROR); } else { $message->setSeveirty(ArcanistLintSeverity::SEVERITY_WARNING); } $this->addLintMessage($message); } } } diff --git a/src/lint/linter/phutilmodule/ArcanistPhutilModuleLinter.php b/src/lint/linter/phutilmodule/ArcanistPhutilModuleLinter.php index 4f6b37a4..62f469e0 100644 --- a/src/lint/linter/phutilmodule/ArcanistPhutilModuleLinter.php +++ b/src/lint/linter/phutilmodule/ArcanistPhutilModuleLinter.php @@ -1,520 +1,525 @@ 'Use of Undeclared Class', self::LINT_UNDECLARED_FUNCTION => 'Use of Undeclared Function', self::LINT_UNDECLARED_INTERFACE => 'Use of Undeclared Interface', self::LINT_UNDECLARED_SOURCE => 'Use of Nonexistent File', self::LINT_UNUSED_SOURCE => 'Unused Source', self::LINT_UNUSED_MODULE => 'Unused Module', self::LINT_INIT_REBUILD => 'Rebuilt __init__.php File', self::LINT_UNKNOWN_CLASS => 'Unknown Class', self::LINT_UNKNOWN_FUNCTION => 'Unknown Function', self::LINT_ANALYZER_SIGNATURE => 'Analyzer: Bad Call Signature', self::LINT_ANALYZER_DYNAMIC => 'Analyzer: Dynamic Dependency', self::LINT_ANALYZER_NO_INIT => 'Analyzer: No __init__.php File', self::LINT_ANALYZER_MULTIPLE_CLASSES => 'Analyzer: File Declares Multiple Classes', ); } public function getLinterName() { return 'PHU'; } public function getLintSeverityMap() { return array( self::LINT_ANALYZER_DYNAMIC => ArcanistLintSeverity::SEVERITY_WARNING, ); } private $moduleInfo = array(); private $unknownClasses = array(); private $unknownFunctions = array(); private function setModuleInfo($key, array $info) { $this->moduleInfo[$key] = $info; } private function getModulePathOnDisk($key) { $info = $this->moduleInfo[$key]; return $info['root'].'/'.$info['module']; } private function getModuleDisplayName($key) { $info = $this->moduleInfo[$key]; return $info['module']; } private function isPhutilLibraryMetadata($path) { $file = basename($path); return !strncmp('__phutil_library_', $file, strlen('__phutil_library_')); } public function willLintPaths(array $paths) { if ($paths) { if (!xhpast_is_available()) { throw new Exception(xhpast_get_build_instructions()); } } $modules = array(); $moduleinfo = array(); $project_root = $this->getEngine()->getWorkingCopy()->getProjectRoot(); foreach ($paths as $path) { $absolute_path = $project_root.'/'.$path; $library_root = phutil_get_library_root_for_path($absolute_path); if (!$library_root) { continue; } if ($this->isPhutilLibraryMetadata($path)) { continue; } $library_name = phutil_get_library_name_for_root($library_root); if (!is_dir($path)) { $path = dirname($path); } $path = Filesystem::resolvePath( $path, $project_root); if ($path == $library_root) { continue; } $module_name = Filesystem::readablePath($path, $library_root); $module_key = $library_name.':'.$module_name; if (empty($modules[$module_key])) { $modules[$module_key] = $module_key; $this->setModuleInfo($module_key, array( 'library' => $library_name, 'root' => $library_root, 'module' => $module_name, )); } } if (!$modules) { return; } $modules = array_keys($modules); $arc_root = phutil_get_library_root('arcanist'); $bin = dirname($arc_root).'/scripts/phutil_analyzer.php'; $futures = array(); foreach ($modules as $mkey => $key) { $disk_path = $this->getModulePathOnDisk($key); if (Filesystem::pathExists($disk_path)) { $futures[$key] = new ExecFuture( '%s %s', $bin, $disk_path); } else { // This can occur in git when you add a module in HEAD and then remove // it in unstaged changes in the working copy. Just ignore it. unset($modules[$mkey]); } } $requirements = array(); foreach (Futures($futures) as $key => $future) { $requirements[$key] = $future->resolveJSON(); } $dependencies = array(); $futures = array(); foreach ($requirements as $key => $requirement) { foreach ($requirement['messages'] as $message) { list($where, $text, $code, $description) = $message; if ($where) { $where = array($where); } $this->raiseLintInModule( $key, $code, $description, $where, $text); } foreach ($requirement['requires']['module'] as $req_module => $where) { if (isset($requirements[$req_module])) { $dependencies[$req_module] = $requirements[$req_module]; } else { list($library_name, $module_name) = explode(':', $req_module); $library_root = phutil_get_library_root($library_name); $this->setModuleInfo($req_module, array( 'library' => $library_name, 'root' => $library_root, 'module' => $module_name, )); $disk_path = $this->getModulePathOnDisk($req_module); if (Filesystem::pathExists($disk_path)) { $futures[$req_module] = new ExecFuture( '%s %s', $bin, $disk_path); } else { $dependencies[$req_module] = array(); } } } } foreach (Futures($futures) as $key => $future) { $dependencies[$key] = $future->resolveJSON(); } foreach ($requirements as $key => $spec) { $deps = array_intersect_key( $dependencies, $spec['requires']['module']); $this->lintModule($key, $spec, $deps); } } private function lintModule($key, $spec, $deps) { $resolvable = array(); $need_classes = array(); $need_functions = array(); $drop_modules = array(); $used = array(); static $types = array( 'class' => self::LINT_UNDECLARED_CLASS, 'interface' => self::LINT_UNDECLARED_INTERFACE, 'function' => self::LINT_UNDECLARED_FUNCTION, ); foreach ($types as $type => $lint_code) { foreach ($spec['requires'][$type] as $name => $places) { $declared = $this->checkDependency( $type, $name, $deps); if (!$declared) { $module = $this->getModuleDisplayName($key); $message = $this->raiseLintInModule( $key, $lint_code, "Module '{$module}' uses {$type} '{$name}' but does not include ". "any module which declares it.", $places); if ($type == 'class' || $type == 'interface') { $loader = new PhutilSymbolLoader(); $loader->setType($type); $loader->setName($name); $symbols = $loader->selectSymbolsWithoutLoading(); if ($symbols) { $class_spec = reset($symbols); try { $loader->selectAndLoadSymbols(); $loaded = true; } catch (PhutilMissingSymbolException $ex) { $loaded = false; } catch (PhutilBootloaderException $ex) { $loaded = false; } if ($loaded) { $resolvable[] = $message; $need_classes[$name] = $class_spec; } else { if (empty($this->unknownClasses[$name])) { $this->unknownClasses[$name] = true; $library = $class_spec['library']; $this->raiseLintInModule( $key, self::LINT_UNKNOWN_CLASS, "Class '{$name}' exists in the library map for library ". "'{$library}', but could not be loaded. You may need to ". "rebuild the library map.", $places); } } } else { if (empty($this->unknownClasses[$name])) { $this->unknownClasses[$name] = true; $this->raiseLintInModule( $key, self::LINT_UNKNOWN_CLASS, "Class '{$name}' could not be found in any known library. ". "You may need to rebuild the map for the library which ". "contains it.", $places); } } } else { $loader = new PhutilSymbolLoader(); $loader->setType($type); $loader->setName($name); $symbols = $loader->selectSymbolsWithoutLoading(); if ($symbols) { $func_spec = reset($symbols); try { $loader->selectAndLoadSymbols(); $loaded = true; } catch (PhutilMissingSymbolException $ex) { $loaded = false; } catch (PhutilBootloaderException $ex) { $loaded = false; } if ($loaded) { $resolvable[] = $message; $need_functions[$name] = $func_spec; } else { if (empty($this->unknownFunctions[$name])) { $this->unknownFunctions[$name] = true; $library = $func_spec['library']; $this->raiseLintInModule( $key, self::LINT_UNKNOWN_FUNCTION, "Function '{$name}' exists in the library map for library ". "'{$library}', but could not be loaded. You may need to ". "rebuild the library map.", $places); } } } else { if (empty($this->unknownFunctions[$name])) { $this->unknownFunctions[$name] = true; $this->raiseLintInModule( $key, self::LINT_UNKNOWN_FUNCTION, "Function '{$name}' could not be found in any known ". "library. You may need to rebuild the map for the library ". "which contains it.", $places); } } } } $used[$declared] = true; } } $unused = array_diff_key($deps, $used); foreach ($unused as $unused_module_key => $ignored) { $module = $this->getModuleDisplayName($key); $unused_module = $this->getModuleDisplayName($unused_module_key); $resolvable[] = $this->raiseLintInModule( $key, self::LINT_UNUSED_MODULE, "Module '{$module}' requires module '{$unused_module}' but does not ". "use anything it declares.", $spec['requires']['module'][$unused_module_key]); $drop_modules[] = $unused_module_key; } foreach ($spec['requires']['source'] as $file => $where) { if (empty($spec['declares']['source'][$file])) { $module = $this->getModuleDisplayName($key); $resolvable[] = $this->raiseLintInModule( $key, self::LINT_UNDECLARED_SOURCE, "Module '{$module}' requires source '{$file}', but it does not ". "exist.", $where); } } foreach ($spec['declares']['source'] as $file => $ignored) { if (empty($spec['requires']['source'][$file])) { $module = $this->getModuleDisplayName($key); $resolvable[] = $this->raiseLintInModule( $key, self::LINT_UNUSED_SOURCE, "Module '{$module}' does not include source file '{$file}'.", null); } } if ($resolvable) { $new_file = $this->buildNewModuleInit( $key, $spec, $need_classes, $need_functions, $drop_modules); $init_path = $this->getModulePathOnDisk($key).'/__init__.php'; $try_path = Filesystem::readablePath($init_path); if (Filesystem::pathExists($try_path)) { $init_path = $try_path; $old_file = Filesystem::readFile($init_path); } else { $old_file = ''; } $this->willLintPath($init_path); $message = $this->raiseLintAtOffset( null, self::LINT_INIT_REBUILD, "This generated phutil '__init__.php' file is suggested to address ". "lint problems with static dependencies in the module.", $old_file, $new_file); $message->setDependentMessages($resolvable); foreach ($resolvable as $message) { $message->setObsolete(true); } $message->setGenerateFile(true); } } private function buildNewModuleInit( $key, $spec, $need_classes, $need_functions, $drop_modules) { $init = array(); $init[] = ' $class_spec) { $modules[$class_spec['library'].':'.$class_spec['module']] = true; } foreach ($need_functions as $need => $func_spec) { $modules[$func_spec['library'].':'.$func_spec['module']] = true; } ksort($modules); $last = null; foreach ($modules as $module_key => $ignored) { if (is_array($ignored)) { $in_init = false; $in_file = false; foreach ($ignored as $where) { list($file, $line) = explode(':', $where); if ($file == '__init__.php') { $in_init = true; } else { $in_file = true; } } if ($in_file && !$in_init) { // If this is a runtime include, don't try to put it in the // __init__ file. continue; } } list($library, $module_name) = explode(':', $module_key); if ($last != $library) { $last = $library; if ($last != null) { $init[] = null; } } $library = "'".addcslashes($library, "'\\")."'"; $module_name = "'".addcslashes($module_name, "'\\")."'"; $init[] = "phutil_require_module({$library}, {$module_name});"; } $init[] = null; $init[] = null; $files = array_keys($spec['declares']['source']); sort($files); foreach ($files as $file) { $file = "'".addcslashes($file, "'\\")."'"; $init[] = "phutil_require_source({$file});"; } $init[] = null; return implode("\n", $init); } private function checkDependency($type, $name, $deps) { foreach ($deps as $key => $dep) { if (isset($dep['declares'][$type][$name])) { return $key; } } return false; } public function raiseLintInModule($key, $code, $desc, $places, $text = null) { if ($places) { foreach ($places as $place) { list($file, $offset) = explode(':', $place); $this->willLintPath( Filesystem::readablePath( $this->getModulePathOnDisk($key).'/'.$file, $this->getEngine()->getWorkingCopy()->getProjectRoot())); return $this->raiseLintAtOffset( $offset, $code, $desc, $text); } } else { $this->willLintPath($this->getModuleDisplayName($key)); return $this->raiseLintAtPath( $code, $desc); } } public function lintPath($path) { return; } } diff --git a/src/lint/linter/text/ArcanistTextLinter.php b/src/lint/linter/text/ArcanistTextLinter.php index 23c24c77..8a54550e 100644 --- a/src/lint/linter/text/ArcanistTextLinter.php +++ b/src/lint/linter/text/ArcanistTextLinter.php @@ -1,207 +1,212 @@ maxLineLength = $new_length; return $this; } public function willLintPaths(array $paths) { return; } public function getLinterName() { return 'TXT'; } public function getLintSeverityMap() { return array( self::LINT_LINE_WRAP => ArcanistLintSeverity::SEVERITY_WARNING, ); } public function getLintNameMap() { return array( self::LINT_DOS_NEWLINE => 'DOS Newlines', self::LINT_TAB_LITERAL => 'Tab Literal', self::LINT_LINE_WRAP => 'Line Too Long', self::LINT_EOF_NEWLINE => 'File Does Not End in Newline', self::LINT_BAD_CHARSET => 'Bad Charset', self::LINT_TRAILING_WHITESPACE => 'Trailing Whitespace', self::LINT_NO_COMMIT => 'Explicit @no'.'commit', ); } public function lintPath($path) { $this->lintNewlines($path); $this->lintTabs($path); if ($this->didStopAllLinters()) { return; } $this->lintCharset($path); if ($this->didStopAllLinters()) { return; } $this->lintLineLength($path); $this->lintEOFNewline($path); $this->lintTrailingWhitespace($path); if ($this->getEngine()->getCommitHookMode()) { $this->lintNoCommit($path); } } protected function lintNewlines($path) { $pos = strpos($this->getData($path), "\r"); if ($pos !== false) { $this->raiseLintAtOffset( $pos, self::LINT_DOS_NEWLINE, 'You must use ONLY Unix linebreaks ("\n") in source code.', "\r"); $this->stopAllLinters(); } } protected function lintTabs($path) { $pos = strpos($this->getData($path), "\t"); if ($pos !== false) { $this->raiseLintAtOffset( $pos, self::LINT_TAB_LITERAL, 'Configure your editor to use spaces for indentation.', "\t"); } } protected function lintLineLength($path) { $lines = explode("\n", $this->getData($path)); $width = $this->maxLineLength; foreach ($lines as $line_idx => $line) { if (strlen($line) > $width) { $this->raiseLintAtLine( $line_idx + 1, 1, self::LINT_LINE_WRAP, 'This line is '.number_format(strlen($line)).' characters long, '. 'but the convention is '.$width.' characters.', $line); } } } protected function lintEOFNewline($path) { $data = $this->getData($path); if (!strlen($data) || $data[strlen($data) - 1] != "\n") { $this->raiseLintAtOffset( strlen($data), self::LINT_EOF_NEWLINE, "Files must end in a newline.", '', "\n"); } } protected function lintCharset($path) { $data = $this->getData($path); $matches = null; $preg = preg_match_all( '/[^\x09\x0A\x20-\x7E]+/', $data, $matches, PREG_OFFSET_CAPTURE); if (!$preg) { return; } foreach ($matches[0] as $match) { list($string, $offset) = $match; $this->raiseLintAtOffset( $offset, self::LINT_BAD_CHARSET, 'Source code should contain only ASCII bytes with ordinal decimal '. 'values between 32 and 126 inclusive, plus linefeed. Do not use UTF-8 '. 'or other multibyte charsets.', $string); } $this->stopAllLinters(); } protected function lintTrailingWhitespace($path) { $data = $this->getData($path); $matches = null; $preg = preg_match_all( '/ +$/m', $data, $matches, PREG_OFFSET_CAPTURE); if (!$preg) { return; } foreach ($matches[0] as $match) { list($string, $offset) = $match; $this->raiseLintAtOffset( $offset, self::LINT_TRAILING_WHITESPACE, 'This line contains trailing whitespace.', $string, ''); } } private function lintNoCommit($path) { $data = $this->getData($path); $deadly = '@no'.'commit'; $offset = strpos($data, $deadly); if ($offset !== false) { $this->raiseLintAtOffset( $offset, self::LINT_NO_COMMIT, 'This file is explicitly marked as "'.$deadly.'", which blocks '. 'commits.', $deadly); } } } diff --git a/src/lint/linter/text/__tests__/ArcanistTextLinterTestCase.php b/src/lint/linter/text/__tests__/ArcanistTextLinterTestCase.php index caff6243..a271bcc0 100644 --- a/src/lint/linter/text/__tests__/ArcanistTextLinterTestCase.php +++ b/src/lint/linter/text/__tests__/ArcanistTextLinterTestCase.php @@ -1,30 +1,35 @@ executeTestsInDirectory( dirname(__FILE__).'/data/', $linter, $working_copy); } } diff --git a/src/lint/linter/xhpast/ArcanistXHPASTLinter.php b/src/lint/linter/xhpast/ArcanistXHPASTLinter.php index fe23fffa..d642c3cf 100644 --- a/src/lint/linter/xhpast/ArcanistXHPASTLinter.php +++ b/src/lint/linter/xhpast/ArcanistXHPASTLinter.php @@ -1,999 +1,1004 @@ 'PHP Syntax Error!', self::LINT_UNABLE_TO_PARSE => 'Unable to Parse', self::LINT_VARIABLE_VARIABLE => 'Use of Variable Variable', self::LINT_EXTRACT_USE => 'Use of extract()', self::LINT_UNDECLARED_VARIABLE => 'Use of Undeclared Variable', self::LINT_PHP_SHORT_TAG => 'Use of Short Tag " 'Use of Echo Tag " 'Use of Close Tag "?>"', self::LINT_NAMING_CONVENTIONS => 'Naming Conventions', self::LINT_IMPLICIT_CONSTRUCTOR => 'Implicit Constructor', self::LINT_FORMATTING_CONVENTIONS => 'Formatting Conventions', self::LINT_DYNAMIC_DEFINE => 'Dynamic define()', self::LINT_STATIC_THIS => 'Use of $this in Static Context', self::LINT_PREG_QUOTE_MISUSE => 'Misuse of preg_quote()', self::LINT_PHP_OPEN_TAG => 'Expected Open Tag', self::LINT_TODO_COMMENT => 'TODO Comment', self::LINT_EXIT_EXPRESSION => 'Exit Used as Expression', self::LINT_COMMENT_STYLE => 'Comment Style', self::LINT_CLASS_FILENAME_MISMATCH => 'Class-Filename Mismatch', self::LINT_TAUTOLOGICAL_EXPRESSION => 'Tautological Expression', ); } public function getLinterName() { return 'XHP'; } public function getLintSeverityMap() { return array( self::LINT_TODO_COMMENT => ArcanistLintSeverity::SEVERITY_ADVICE, self::LINT_FORMATTING_CONVENTIONS => ArcanistLintSeverity::SEVERITY_WARNING, self::LINT_NAMING_CONVENTIONS => ArcanistLintSeverity::SEVERITY_WARNING, ); } public function willLintPaths(array $paths) { $futures = array(); foreach ($paths as $path) { $futures[$path] = xhpast_get_parser_future($this->getData($path)); } foreach ($futures as $path => $future) { $this->willLintPath($path); try { $this->trees[$path] = XHPASTTree::newFromDataAndResolvedExecFuture( $this->getData($path), $future->resolve()); } catch (XHPASTSyntaxErrorException $ex) { $this->raiseLintAtLine( $ex->getErrorLine(), 1, self::LINT_PHP_SYNTAX_ERROR, 'This file contains a syntax error: '.$ex->getMessage()); $this->stopAllLinters(); return; } catch (Exception $ex) { $this->raiseLintAtPath( self::LINT_UNABLE_TO_PARSE, 'XHPAST could not parse this file, probably because the AST is too '. 'deep. Some lint issues may not have been detected. You may safely '. 'ignore this warning.'); return; } } } public function getXHPASTTreeForPath($path) { return idx($this->trees, $path); } public function lintPath($path) { if (empty($this->trees[$path])) { return; } $root = $this->trees[$path]->getRootNode(); $this->lintUseOfThisInStaticMethods($root); $this->lintDynamicDefines($root); $this->lintSurpriseConstructors($root); $this->lintPHPTagUse($root); $this->lintVariableVariables($root); $this->lintTODOComments($root); $this->lintExitExpressions($root); $this->lintSpaceAroundBinaryOperators($root); $this->lintSpaceAfterControlStatementKeywords($root); $this->lintParenthesesShouldHugExpressions($root); $this->lintNamingConventions($root); $this->lintPregQuote($root); $this->lintUndeclaredVariables($root); $this->lintArrayIndexWhitespace($root); $this->lintHashComments($root); $this->lintPrimaryDeclarationFilenameMatch($root); $this->lintTautologicalExpressions($root); } private function lintTautologicalExpressions($root) { $expressions = $root->selectDescendantsOfType('n_BINARY_EXPRESSION'); static $operators = array( '-' => true, '/' => true, '-=' => true, '/=' => true, '<=' => true, '<' => true, '==' => true, '===' => true, '!=' => true, '!==' => true, '>=' => true, '>' => true, ); foreach ($expressions as $expr) { $operator = $expr->getChildByIndex(1)->getConcreteString(); if (empty($operators[$operator])) { continue; } $left = $expr->getChildByIndex(0)->getSemanticString(); $right = $expr->getChildByIndex(2)->getSemanticString(); if ($left == $right) { $this->raiseLintAtNode( $expr, self::LINT_TAUTOLOGICAL_EXPRESSION, 'Both sides of this expression are identical, so it always '. 'evaluates to a constant.'); } } } protected function lintHashComments($root) { $tokens = $root->getTokens(); foreach ($tokens as $token) { if ($token->getTypeName() == 'T_COMMENT') { $value = $token->getValue(); if ($value[0] == '#') { $this->raiseLintAtOffset( $token->getOffset(), self::LINT_COMMENT_STYLE, 'Use "//" single-line comments, not "#".', '#', '//'); } } } } protected function lintVariableVariables($root) { $vvars = $root->selectDescendantsOfType('n_VARIABLE_VARIABLE'); foreach ($vvars as $vvar) { $this->raiseLintAtNode( $vvar, self::LINT_VARIABLE_VARIABLE, 'Rewrite this code to use an array. Variable variables are unclear '. 'and hinder static analysis.'); } } protected function lintUndeclaredVariables($root) { // These things declare variables in a function: // Explicit parameters // Assignment // Assignment via list() // Static // Global // Lexical vars // Builtins ($this) // foreach() // catch // // These things make lexical scope unknowable: // Use of extract() // Assignment to variable variables ($$x) // Global with variable variables // // These things don't count as "using" a variable: // isset() // empty() // Static class variables // // The general approach here is to find each function/method declaration, // then: // // 1. Identify all the variable declarations, and where they first occur // in the function/method declaration. // 2. Identify all the uses that don't really count (as above). // 3. Everything else must be a use of a variable. // 4. For each variable, check if any uses occur before the declaration // and warn about them. // // We also keep track of where lexical scope becomes unknowable (e.g., // because the function calls extract() or uses dynamic variables, // preventing us from keeping track of which variables are defined) so we // can stop issuing warnings after that. $fdefs = $root->selectDescendantsOfType('n_FUNCTION_DECLARATION'); $mdefs = $root->selectDescendantsOfType('n_METHOD_DECLARATION'); $defs = $fdefs->add($mdefs); foreach ($defs as $def) { // We keep track of the first offset where scope becomes unknowable, and // silence any warnings after that. Default it to INT_MAX so we can min() // it later to keep track of the first problem we encounter. $scope_destroyed_at = PHP_INT_MAX; $declarations = array( '$this' => 0, '$GLOBALS' => 0, '$_SERVER' => 0, '$_GET' => 0, '$_POST' => 0, '$_FILES' => 0, '$_COOKIE' => 0, '$_SESSION' => 0, '$_REQUEST' => 0, '$_ENV' => 0, ); $declaration_tokens = array(); $exclude_tokens = array(); $vars = array(); // First up, find all the different kinds of declarations, as explained // above. Put the tokens into the $vars array. $param_list = $def->getChildOfType(3, 'n_DECLARATION_PARAMETER_LIST'); $param_vars = $param_list->selectDescendantsOfType('n_VARIABLE'); foreach ($param_vars as $var) { $vars[] = $var; } // This is PHP5.3 closure syntax: function () use ($x) {}; $lexical_vars = $def ->getChildByIndex(4) ->selectDescendantsOfType('n_VARIABLE'); foreach ($lexical_vars as $var) { $vars[] = $var; } $body = $def->getChildByIndex(5); if ($body->getTypeName() == 'n_EMPTY') { // Abstract method declaration. continue; } $static_vars = $body ->selectDescendantsOfType('n_STATIC_DECLARATION') ->selectDescendantsOfType('n_VARIABLE'); foreach ($static_vars as $var) { $vars[] = $var; } $global_vars = $body ->selectDescendantsOfType('n_GLOBAL_DECLARATION_LIST'); foreach ($global_vars as $var_list) { foreach ($var_list->getChildren() as $var) { if ($var->getTypeName() == 'n_VARIABLE') { $vars[] = $var; } else { // Dynamic global variable, i.e. "global $$x;". $scope_destroyed_at = min($scope_destroyed_at, $var->getOffset()); // An error is raised elsewhere, no need to raise here. } } } $catches = $body ->selectDescendantsOfType('n_CATCH') ->selectDescendantsOfType('n_VARIABLE'); foreach ($catches as $var) { $vars[] = $var; } $foreaches = $body->selectDescendantsOfType('n_FOREACH_EXPRESSION'); foreach ($foreaches as $foreach_expr) { $key_var = $foreach_expr->getChildByIndex(1); if ($key_var->getTypeName() == 'n_VARIABLE') { $vars[] = $key_var; } $value_var = $foreach_expr->getChildByIndex(2); if ($value_var->getTypeName() == 'n_VARIABLE') { $vars[] = $value_var; } else { // The root-level token may be a reference, as in: // foreach ($a as $b => &$c) { ... } // Reach into the n_VARIABLE_REFERENCE node to grab the n_VARIABLE // node. $vars[] = $value_var->getChildOfType(0, 'n_VARIABLE'); } } $binary = $body->selectDescendantsOfType('n_BINARY_EXPRESSION'); foreach ($binary as $expr) { if ($expr->getChildByIndex(1)->getConcreteString() != '=') { continue; } $lval = $expr->getChildByIndex(0); if ($lval->getTypeName() == 'n_VARIABLE') { $vars[] = $lval; } else if ($lval->getTypeName() == 'n_LIST') { // Recursivey grab everything out of list(), since the grammar // permits list() to be nested. Also note that list() is ONLY valid // as an lval assignments, so we could safely lift this out of the // n_BINARY_EXPRESSION branch. $assign_vars = $lval->selectDescendantsOfType('n_VARIABLE'); foreach ($assign_vars as $var) { $vars[] = $var; } } if ($lval->getTypeName() == 'n_VARIABLE_VARIABLE') { $scope_destroyed_at = min($scope_destroyed_at, $lval->getOffset()); // No need to raise here since we raise an error elsewhere. } } $calls = $body->selectDescendantsOfType('n_FUNCTION_CALL'); foreach ($calls as $call) { $name = strtolower($call->getChildByIndex(0)->getConcreteString()); if ($name == 'empty' || $name == 'isset') { $params = $call ->getChildOfType(1, 'n_CALL_PARAMETER_LIST') ->selectDescendantsOfType('n_VARIABLE'); foreach ($params as $var) { $exclude_tokens[$var->getID()] = true; } continue; } if ($name != 'extract') { continue; } $scope_destroyed_at = min($scope_destroyed_at, $call->getOffset()); $this->raiseLintAtNode( $call, self::LINT_EXTRACT_USE, 'Avoid extract(). It is confusing and hinders static analysis.'); } // Now we have every declaration. Build two maps, one which just keeps // track of which tokens are part of declarations ($declaration_tokens) // and one which has the first offset where a variable is declared // ($declarations). foreach ($vars as $var) { $concrete = $var->getConcreteString(); $declarations[$concrete] = min( idx($declarations, $concrete, PHP_INT_MAX), $var->getOffset()); $declaration_tokens[$var->getID()] = true; } // Excluded tokens are ones we don't "count" as being uses, described // above. Put them into $exclude_tokens. $class_statics = $body ->selectDescendantsOfType('n_CLASS_STATIC_ACCESS'); $class_static_vars = $class_statics ->selectDescendantsOfType('n_VARIABLE'); foreach ($class_static_vars as $var) { $exclude_tokens[$var->getID()] = true; } // Issue a warning for every variable token, unless it appears in a // declaration, we know about a prior declaration, we have explicitly // exlcuded it, or scope has been made unknowable before it appears. $all_vars = $body->selectDescendantsOfType('n_VARIABLE'); $issued_warnings = array(); foreach ($all_vars as $var) { if (isset($declaration_tokens[$var->getID()])) { // We know this is part of a declaration, so it's fine. continue; } if (isset($exclude_tokens[$var->getID()])) { // We know this is part of isset() or similar, so it's fine. continue; } if ($var->getOffset() >= $scope_destroyed_at) { // This appears after an extract() or $$var so we have no idea // whether it's legitimate or not. We raised a harshly-worded warning // when scope was made unknowable, so just ignore anything we can't // figure out. continue; } $concrete = $var->getConcreteString(); if ($var->getOffset() >= idx($declarations, $concrete, PHP_INT_MAX)) { // The use appears after the variable is declared, so it's fine. continue; } if (!empty($issued_warnings[$concrete])) { // We've already issued a warning for this variable so we don't need // to issue another one. continue; } $this->raiseLintAtNode( $var, self::LINT_UNDECLARED_VARIABLE, 'Declare variables prior to use (even if you are passing them '. 'as reference parameters). You may have misspelled this '. 'variable name.'); $issued_warnings[$concrete] = true; } } } protected function lintPHPTagUse($root) { $tokens = $root->getTokens(); foreach ($tokens as $token) { if ($token->getTypeName() == 'T_OPEN_TAG') { if (trim($token->getValue()) == 'raiseLintAtToken( $token, self::LINT_PHP_SHORT_TAG, 'Use the full form of the PHP open tag, "getTypeName() == 'T_OPEN_TAG_WITH_ECHO') { $this->raiseLintAtToken( $token, self::LINT_PHP_ECHO_TAG, 'Avoid the PHP echo short form, "getValue())) { $this->raiseLintAtToken( $token, self::LINT_PHP_OPEN_TAG, 'PHP files should start with "getTypeName() == 'T_CLOSE_TAG') { $this->raiseLintAtToken( $token, self::LINT_PHP_CLOSE_TAG, 'Do not use the PHP closing tag, "?>".'); } } } protected function lintNamingConventions($root) { $classes = $root->selectDescendantsOfType('n_CLASS_DECLARATION'); foreach ($classes as $class) { $name_token = $class->getChildByIndex(1); $name_string = $name_token->getConcreteString(); $is_xhp = ($name_string[0] == ':'); if ($is_xhp) { if (!$this->isLowerCaseWithXHP($name_string)) { $this->raiseLintAtNode( $name_token, self::LINT_NAMING_CONVENTIONS, 'Follow naming conventions: xhp elements should be named using '. 'lower case.'); } } else { if (!$this->isUpperCamelCase($name_string)) { $this->raiseLintAtNode( $name_token, self::LINT_NAMING_CONVENTIONS, 'Follow naming conventions: classes should be named using '. 'UpperCamelCase.'); } } } $ifaces = $root->selectDescendantsOfType('n_INTERFACE_DECLARATION'); foreach ($ifaces as $iface) { $name_token = $iface->getChildByIndex(1); $name_string = $name_token->getConcreteString(); if (!$this->isUpperCamelCase($name_string)) { $this->raiseLintAtNode( $name_token, self::LINT_NAMING_CONVENTIONS, 'Follow naming conventions: interfaces should be named using '. 'UpperCamelCase.'); } } $functions = $root->selectDescendantsOfType('n_FUNCTION_DECLARATION'); foreach ($functions as $function) { $name_token = $function->getChildByIndex(2); if ($name_token->getTypeName() == 'n_EMPTY') { // Unnamed closure. continue; } $name_string = $name_token->getConcreteString(); if (!$this->isLowercaseWithUnderscores($name_string)) { $this->raiseLintAtNode( $name_token, self::LINT_NAMING_CONVENTIONS, 'Follow naming conventions: functions should be named using '. 'lowercase_with_underscores.'); } } $methods = $root->selectDescendantsOfType('n_METHOD_DECLARATION'); foreach ($methods as $method) { $name_token = $method->getChildByIndex(2); $name_string = $name_token->getConcreteString(); if (!$this->isLowerCamelCase($name_string)) { $this->raiseLintAtNode( $name_token, self::LINT_NAMING_CONVENTIONS, 'Follow naming conventions: methods should be named using '. 'lowerCamelCase.'); } } $params = $root->selectDescendantsOfType('n_DECLARATION_PARAMETER_LIST'); foreach ($params as $param_list) { foreach ($param_list->getChildren() as $param) { $name_token = $param->getChildByIndex(1); $name_string = $name_token->getConcreteString(); if (!$this->isLowercaseWithUnderscores($name_string)) { $this->raiseLintAtNode( $name_token, self::LINT_NAMING_CONVENTIONS, 'Follow naming conventions: parameters should be named using '. 'lowercase_with_underscores.'); } } } $constants = $root->selectDescendantsOfType( 'n_CLASS_CONSTANT_DECLARATION_LIST'); foreach ($constants as $constant_list) { foreach ($constant_list->getChildren() as $constant) { $name_token = $constant->getChildByIndex(0); $name_string = $name_token->getConcreteString(); if (!$this->isUppercaseWithUnderscores($name_string)) { $this->raiseLintAtNode( $name_token, self::LINT_NAMING_CONVENTIONS, 'Follow naming conventions: class constants should be named using '. 'UPPERCASE_WITH_UNDERSCORES.'); } } } $props = $root->selectDescendantsOfType('n_CLASS_MEMBER_DECLARATION_LIST'); foreach ($props as $prop_list) { foreach ($prop_list->getChildren() as $prop) { if ($prop->getTypeName() == 'n_CLASS_MEMBER_MODIFIER_LIST') { continue; } $name_token = $prop->getChildByIndex(0); $name_string = $name_token->getConcreteString(); if (!$this->isLowerCamelCase($name_string)) { $this->raiseLintAtNode( $name_token, self::LINT_NAMING_CONVENTIONS, 'Follow naming conventions: class properties should be named '. 'using lowerCamelCase.'); } } } } protected function isUpperCamelCase($str) { return preg_match('/^[A-Z][A-Za-z0-9]*$/', $str); } protected function isLowerCamelCase($str) { // Allow initial "__" for magic methods like __construct; we could also // enumerate these explicitly. return preg_match('/^\$?(?:__)?[a-z][A-Za-z0-9]*$/', $str); } protected function isUppercaseWithUnderscores($str) { return preg_match('/^[A-Z0-9_]+$/', $str); } protected function isLowercaseWithUnderscores($str) { return preg_match('/^[&]?\$?[a-z0-9_]+$/', $str); } protected function isLowercaseWithXHP($str) { return preg_match('/^:[a-z0-9_:-]+$/', $str); } protected function lintSurpriseConstructors($root) { $classes = $root->selectDescendantsOfType('n_CLASS_DECLARATION'); foreach ($classes as $class) { $class_name = $class->getChildByIndex(1)->getConcreteString(); $methods = $class->selectDescendantsOfType('n_METHOD_DECLARATION'); foreach ($methods as $method) { $method_name_token = $method->getChildByIndex(2); $method_name = $method_name_token->getConcreteString(); if (strtolower($class_name) == strtolower($method_name)) { $this->raiseLintAtNode( $method_name_token, self::LINT_IMPLICIT_CONSTRUCTOR, 'Name constructors __construct() explicitly. This method is a '. 'constructor because it has the same name as the class it is '. 'defined in.'); } } } } protected function lintParenthesesShouldHugExpressions($root) { $calls = $root->selectDescendantsOfType('n_CALL_PARAMETER_LIST'); $controls = $root->selectDescendantsOfType('n_CONTROL_CONDITION'); $fors = $root->selectDescendantsOfType('n_FOR_EXPRESSION'); $foreach = $root->selectDescendantsOfType('n_FOREACH_EXPRESSION'); $decl = $root->selectDescendantsOfType('n_DECLARATION_PARAMETER_LIST'); $all_paren_groups = $calls ->add($controls) ->add($fors) ->add($foreach) ->add($decl); foreach ($all_paren_groups as $group) { $tokens = $group->getTokens(); $token_o = array_shift($tokens); $token_c = array_pop($tokens); if ($token_o->getTypeName() != '(') { throw new Exception('Expected open paren!'); } if ($token_c->getTypeName() != ')') { throw new Exception('Expected close paren!'); } $nonsem_o = $token_o->getNonsemanticTokensAfter(); $nonsem_c = $token_c->getNonsemanticTokensBefore(); if (!$nonsem_o) { continue; } $raise = array(); $string_o = implode('', mpull($nonsem_o, 'getValue')); if (preg_match('/^[ ]+$/', $string_o)) { $raise[] = array($nonsem_o, $string_o); } if ($nonsem_o !== $nonsem_c) { $string_c = implode('', mpull($nonsem_c, 'getValue')); if (preg_match('/^[ ]+$/', $string_c)) { $raise[] = array($nonsem_c, $string_c); } } foreach ($raise as $warning) { list($tokens, $string) = $warning; $this->raiseLintAtOffset( reset($tokens)->getOffset(), self::LINT_FORMATTING_CONVENTIONS, 'Parentheses should hug their contents.', $string, ''); } } } protected function lintSpaceAfterControlStatementKeywords($root) { foreach ($root->getTokens() as $id => $token) { switch ($token->getTypeName()) { case 'T_IF': case 'T_ELSE': case 'T_FOR': case 'T_FOREACH': case 'T_WHILE': case 'T_DO': case 'T_SWITCH': $after = $token->getNonsemanticTokensAfter(); if (empty($after)) { $this->raiseLintAtToken( $token, self::LINT_FORMATTING_CONVENTIONS, 'Convention: put a space after control statements.', $token->getValue().' '); } break; } } } protected function lintSpaceAroundBinaryOperators($root) { $expressions = $root->selectDescendantsOfType('n_BINARY_EXPRESSION'); foreach ($expressions as $expression) { $operator = $expression->getChildByIndex(1); $operator_value = $operator->getConcreteString(); if ($operator_value == '.') { // TODO: implement this check continue; } else { list($before, $after) = $operator->getSurroundingNonsemanticTokens(); $replace = null; if (empty($before) && empty($after)) { $replace = " {$operator_value} "; } else if (empty($before)) { $replace = " {$operator_value}"; } else if (empty($after)) { $replace = "{$operator_value} "; } if ($replace !== null) { $this->raiseLintAtNode( $operator, self::LINT_FORMATTING_CONVENTIONS, 'Convention: logical and arithmetic operators should be '. 'surrounded by whitespace.', $replace); } } } } protected function lintDynamicDefines($root) { $calls = $root->selectDescendantsOfType('n_FUNCTION_CALL'); foreach ($calls as $call) { $name = $call->getChildByIndex(0)->getConcreteString(); if (strtolower($name) == 'define') { $parameter_list = $call->getChildOfType(1, 'n_CALL_PARAMETER_LIST'); $defined = $parameter_list->getChildByIndex(0); if (!$defined->isStaticScalar()) { $this->raiseLintAtNode( $defined, self::LINT_DYNAMIC_DEFINE, 'First argument to define() must be a string literal.'); } } } } protected function lintUseOfThisInStaticMethods($root) { $classes = $root->selectDescendantsOfType('n_CLASS_DECLARATION'); foreach ($classes as $class) { $methods = $class->selectDescendantsOfType('n_METHOD_DECLARATION'); foreach ($methods as $method) { $attributes = $method ->getChildByIndex(0, 'n_METHOD_MODIFIER_LIST') ->selectDescendantsOfType('n_STRING'); $method_is_static = false; $method_is_abstract = false; foreach ($attributes as $attribute) { if (strtolower($attribute->getConcreteString()) == 'static') { $method_is_static = true; } if (strtolower($attribute->getConcreteString()) == 'abstract') { $method_is_abstract = true; } } if ($method_is_abstract) { continue; } if (!$method_is_static) { continue; } $body = $method->getChildOfType(5, 'n_STATEMENT_LIST'); $variables = $body->selectDescendantsOfType('n_VARIABLE'); foreach ($variables as $variable) { if ($method_is_static && strtolower($variable->getConcreteString()) == '$this') { $this->raiseLintAtNode( $variable, self::LINT_STATIC_THIS, 'You can not reference "$this" inside a static method.'); } } } } } /** * preg_quote() takes two arguments, but the second one is optional because * PHP is awesome. If you don't pass a second argument, you're probably * going to get something wrong. */ protected function lintPregQuote($root) { $function_calls = $root->selectDescendantsOfType('n_FUNCTION_CALL'); foreach ($function_calls as $call) { $name = $call->getChildByIndex(0)->getConcreteString(); if (strtolower($name) === 'preg_quote') { $parameter_list = $call->getChildOfType(1, 'n_CALL_PARAMETER_LIST'); if (count($parameter_list->getChildren()) !== 2) { $this->raiseLintAtNode( $call, self::LINT_PREG_QUOTE_MISUSE, 'You should always pass two arguments to preg_quote(), so that ' . 'preg_quote() knows which delimiter to escape.'); } } } } /** * Exit is parsed as an expression, but using it as such is almost always * wrong. That is, this is valid: * * strtoupper(33 * exit - 6); * * When exit is used as an expression, it causes the program to terminate with * exit code 0. This is likely not what is intended; these statements have * different effects: * * exit(-1); * exit -1; * * The former exits with a failure code, the latter with a success code! */ protected function lintExitExpressions($root) { $unaries = $root->selectDescendantsOfType('n_UNARY_PREFIX_EXPRESSION'); foreach ($unaries as $unary) { $operator = $unary->getChildByIndex(0)->getConcreteString(); if (strtolower($operator) == 'exit') { if ($unary->getParentNode()->getTypeName() != 'n_STATEMENT') { $this->raiseLintAtNode( $unary, self::LINT_EXIT_EXPRESSION, "Use exit as a statement, not an expression."); } } } } private function lintArrayIndexWhitespace($root) { $indexes = $root->selectDescendantsOfType('n_INDEX_ACCESS'); foreach ($indexes as $index) { $tokens = $index->getChildByIndex(0)->getTokens(); $last = array_pop($tokens); $trailing = $last->getNonsemanticTokensAfter(); $trailing_text = implode('', mpull($trailing, 'getValue')); if (preg_match('/^ +$/', $trailing_text)) { $this->raiseLintAtOffset( $last->getOffset() + strlen($last->getValue()), self::LINT_FORMATTING_CONVENTIONS, 'Convention: no spaces before index access.', $trailing_text, ''); } } } protected function lintTODOComments($root) { $tokens = $root->getTokens(); foreach ($tokens as $token) { if (!$token->isComment()) { continue; } $value = $token->getValue(); $matches = null; $preg = preg_match_all( '/TODO/', $value, $matches, PREG_OFFSET_CAPTURE); foreach ($matches[0] as $match) { list($string, $offset) = $match; $this->raiseLintAtOffset( $token->getOffset() + $offset, self::LINT_TODO_COMMENT, 'This comment has a TODO.', $string); } } } /** * Lint that if the file declares exactly one interface or class, * the name of the file matches the name of the class, * unless the classname is funky like an XHP element. */ private function lintPrimaryDeclarationFilenameMatch($root) { $classes = $root->selectDescendantsOfType('n_CLASS_DECLARATION'); $interfaces = $root->selectDescendantsOfType('n_INTERFACE_DECLARATION'); if (count($classes) + count($interfaces) != 1) { return; } $declarations = count($classes) ? $classes : $interfaces; $declarations->rewind(); $declaration = $declarations->current(); $decl_name = $declaration->getChildByIndex(1); $decl_string = $decl_name->getConcreteString(); // Exclude strangely named classes, e.g. XHP tags. if (!preg_match('/^\w+$/', $decl_string)) { return; } $rename = $decl_string.'.php'; $path = $this->getActivePath(); $filename = basename($path); if ($rename == $filename) { return; } $this->raiseLintAtNode( $decl_name, self::LINT_CLASS_FILENAME_MISMATCH, "The name of this file differs from the name of the class or interface ". "it declares. Rename the file to '{$rename}'." ); } protected function raiseLintAtToken( XHPASTToken $token, $code, $desc, $replace = null) { return $this->raiseLintAtOffset( $token->getOffset(), $code, $desc, $token->getValue(), $replace); } protected function raiseLintAtNode( XHPASTNode $node, $code, $desc, $replace = null) { return $this->raiseLintAtOffset( $node->getOffset(), $code, $desc, $node->getConcreteString(), $replace); } } diff --git a/src/lint/linter/xhpast/__tests__/ArcanistXHPASTLinterTestCase.php b/src/lint/linter/xhpast/__tests__/ArcanistXHPASTLinterTestCase.php index ccc71831..fa0e41ee 100644 --- a/src/lint/linter/xhpast/__tests__/ArcanistXHPASTLinterTestCase.php +++ b/src/lint/linter/xhpast/__tests__/ArcanistXHPASTLinterTestCase.php @@ -1,30 +1,35 @@ executeTestsInDirectory( dirname(__FILE__).'/data/', $linter, $working_copy); } } diff --git a/src/lint/message/ArcanistLintMessage.php b/src/lint/message/ArcanistLintMessage.php index 6a8e1495..5320a202 100644 --- a/src/lint/message/ArcanistLintMessage.php +++ b/src/lint/message/ArcanistLintMessage.php @@ -1,189 +1,194 @@ setPath($dict['path']); $message->setLine($dict['line']); $message->setChar($dict['char']); $message->setCode($dict['code']); $message->setSeverity($dict['severity']); $message->setName($dict['name']); $message->setDescription($dict['description']); if (isset($dict['original'])) { $message->setOriginalText($dict['original']); } if (isset($dict['replacement'])) { $message->setReplacementText($dict['replacement']); } return $message; } public function setPath($path) { $this->path = $path; return $this; } public function getPath() { return $this->path; } public function setLine($line) { $this->line = $line; return $this; } public function getLine() { return $this->line; } public function setChar($char) { $this->char = $char; return $this; } public function getChar() { return $this->char; } public function setCode($code) { $this->code = $code; return $this; } public function getCode() { return $this->code; } public function setSeverity($severity) { $this->severity = $severity; return $this; } public function getSeverity() { return $this->severity; } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function setDescription($description) { $this->description = $description; return $this; } public function getDescription() { return $this->description; } public function setOriginalText($original) { $this->originalText = $original; return $this; } public function getOriginalText() { return $this->originalText; } public function setReplacementText($replacement) { $this->replacementText = $replacement; return $this; } public function getReplacementText() { return $this->replacementText; } public function isError() { return $this->getSeverity() == ArcanistLintSeverity::SEVERITY_ERROR; } public function isWarning() { return $this->getSeverity() == ArcanistLintSeverity::SEVERITY_WARNING; } public function hasFileContext() { return ($this->getLine() !== null); } public function setGenerateFile($generate_file) { $this->generateFile = $generate_file; return $this; } public function getGenerateFile() { return $this->generateFile; } public function setObsolete($obsolete) { $this->obsolete = $obsolete; return $this; } public function getObsolete() { return $this->obsolete; } public function isPatchable() { return ($this->getReplacementText() !== null); } public function didApplyPatch() { if ($this->appliedToDisk) { return; } $this->appliedToDisk = true; foreach ($this->dependentMessages as $message) { $message->didApplyPatch(); } return $this; } public function isPatchApplied() { return $this->appliedToDisk; } public function setDependentMessages(array $messages) { $this->dependentMessages = $messages; return $this; } } diff --git a/src/lint/patcher/ArcanistLintPatcher.php b/src/lint/patcher/ArcanistLintPatcher.php index e8289845..eefb8ad6 100644 --- a/src/lint/patcher/ArcanistLintPatcher.php +++ b/src/lint/patcher/ArcanistLintPatcher.php @@ -1,151 +1,156 @@ 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 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); $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/renderer/ArcanistLintRenderer.php b/src/lint/renderer/ArcanistLintRenderer.php index 12510377..0fca7044 100644 --- a/src/lint/renderer/ArcanistLintRenderer.php +++ b/src/lint/renderer/ArcanistLintRenderer.php @@ -1,190 +1,195 @@ summaryMode = $mode; } public function renderLintResult(ArcanistLintResult $result) { if ($this->summaryMode) { return $this->renderResultSummary($result); } else { return $this->renderResultFull($result); } } protected function renderResultFull(ArcanistLintResult $result) { $messages = $result->getMessages(); $path = $result->getPath(); $lines = explode("\n", $result->getData()); $text = array(); $text[] = phutil_console_format('**>>>** Lint for __%s__:', $path); $text[] = null; foreach ($messages as $message) { if ($message->isError()) { $color = 'red'; } else { $color = 'yellow'; } $severity = ArcanistLintSeverity::getStringForSeverity( $message->getSeverity()); $code = $message->getCode(); $name = $message->getName(); $description = phutil_console_wrap($message->getDescription(), 4); $text[] = phutil_console_format( " ** %s ** (%s) __%s__\n". " %s\n", $severity, $code, $name, $description); if ($message->hasFileContext()) { $text[] = $this->renderContext($message, $lines); } } $text[] = null; $text[] = null; return implode("\n", $text); } protected function renderResultSummary(ArcanistLintResult $result) { $messages = $result->getMessages(); $path = $result->getPath(); $text = array(); $text[] = $path.":"; foreach ($messages as $message) { $name = $message->getName(); $severity = ArcanistLintSeverity::getStringForSeverity( $message->getSeverity()); $line = $message->getLine(); $text[] = " {$severity} on line {$line}: {$name}"; } $text[] = null; return implode("\n", $text); } protected function renderContext( ArcanistLintMessage $message, array $line_data) { $lines_of_context = 3; $out = array(); $line_num = min($message->getLine(), count($line_data)); $line_num = max(1, $line_num); // Print out preceding context before the impacted region. $cursor = max(1, $line_num - $lines_of_context); for (; $cursor < $line_num; $cursor++) { $out[] = $this->renderLine($cursor, $line_data[$cursor - 1]); } // Print out the impacted region itself. $diff = $message->isPatchable() ? '-' : null; $text = $message->getOriginalText(); $text_lines = explode("\n", $text); $text_length = count($text_lines); for (; $cursor < $line_num + $text_length; $cursor++) { $chevron = ($cursor == $line_num); // We may not have any data if, e.g., the old file does not exist. $data = idx($line_data, $cursor - 1, null); // Highlight the problem substring. $text_line = $text_lines[$cursor - $line_num]; if (strlen($text_line)) { $data = substr_replace( $data, phutil_console_format('##%s##', $text_line), ($cursor == $line_num) ? $message->getChar() - 1 : 0, strlen($text_line)); } $out[] = $this->renderLine($cursor, $data, $chevron, $diff); } if ($message->isPatchable()) { $patch = $message->getReplacementText(); $patch_lines = explode("\n", $patch); $offset = 0; foreach ($patch_lines as $patch_line) { if (isset($line_data[$line_num - 1 + $offset])) { $base = $line_data[$line_num - 1 + $offset]; } else { $base = ''; } if ($offset == 0) { $start = $message->getChar() - 1; } else { $start = 0; } if (isset($text_lines[$offset])) { $len = strlen($text_lines[$offset]); } else { $len = 0; } $patched = substr_replace( $base, phutil_console_format('##%s##', $patch_line), $start, $len); $out[] = $this->renderLine(null, $patched, false, '+'); $offset++; } } $lines_count = count($line_data); $end = min($lines_count, $cursor + $lines_of_context); for (; $cursor < $end; $cursor++) { $out[] = $this->renderLine($cursor, $line_data[$cursor - 1]); } $out[] = null; return implode("\n", $out); } protected function renderLine($line, $data, $chevron = false, $diff = null) { $chevron = $chevron ? '>>>' : ''; return sprintf( " %3s %1s %6s %s", $chevron, $diff, $line, $data); } } diff --git a/src/lint/result/ArcanistLintResult.php b/src/lint/result/ArcanistLintResult.php index d33dee42..69fdad8b 100644 --- a/src/lint/result/ArcanistLintResult.php +++ b/src/lint/result/ArcanistLintResult.php @@ -1,105 +1,110 @@ path = $path; return $this; } public function getPath() { return $this->path; } public function addMessage(ArcanistLintMessage $message) { $this->messages[] = $message; $this->needsSort = true; return $this; } public function getMessages() { if ($this->needsSort) { $this->sortAndFilterMessages(); } return $this->effectiveMessages; } public function setData($data) { $this->data = $data; return $this; } public function getData() { return $this->data; } public function setFilePathOnDisk($file_path_on_disk) { $this->filePathOnDisk = $file_path_on_disk; return $this; } public function getFilePathOnDisk() { return $this->filePathOnDisk; } public function isPatchable() { foreach ($this->messages as $message) { if ($message->isPatchable()) { return true; } } return false; } private function sortAndFilterMessages() { $messages = $this->messages; foreach ($messages as $key => $message) { if ($message->getObsolete()) { unset($messages[$key]); continue; } if ($message->getGenerateFile()) { $messages = array( $key => $message, ); break; } } $map = array(); foreach ($messages as $key => $message) { $map[$key] = ($message->getLine() * (2 << 12)) + $message->getChar(); } asort($map); $messages = array_select_keys($messages, array_keys($map)); $this->effectiveMessages = $messages; $this->needsSort = false; } } diff --git a/src/lint/severity/ArcanistLintSeverity.php b/src/lint/severity/ArcanistLintSeverity.php index 96ad5398..62b11591 100644 --- a/src/lint/severity/ArcanistLintSeverity.php +++ b/src/lint/severity/ArcanistLintSeverity.php @@ -1,61 +1,66 @@ 'Advice', self::SEVERITY_WARNING => 'Warning', self::SEVERITY_ERROR => 'Error', self::SEVERITY_DISABLED => 'Disabled', ); if (!array_key_exists($severity_code, $map)) { throw new Exception("Unknown lint severity '{$severity_code}'!"); } return $map[$severity_code]; } public static function isAtLeastAsSevere( ArcanistLintMessage $message, $level) { static $map = array( self::SEVERITY_DISABLED => 10, self::SEVERITY_ADVICE => 20, self::SEVERITY_WARNING => 30, self::SEVERITY_ERROR => 40, ); $message_sev = $message->getSeverity(); if (empty($map[$message_sev])) { return true; } return $map[$message_sev] >= idx($map, $level, 0); } } diff --git a/src/parser/bundle/ArcanistBundle.php b/src/parser/bundle/ArcanistBundle.php index 94f6c722..8be13f4a 100644 --- a/src/parser/bundle/ArcanistBundle.php +++ b/src/parser/bundle/ArcanistBundle.php @@ -1,337 +1,342 @@ changes = $changes; return $obj; } public static function newFromArcBundle($path) { $path = Filesystem::resolvePath($path); $future = new ExecFuture( csprintf( 'tar xfO %s changes.json', $path)); $changes = $future->resolveJSON(); foreach ($changes as $change_key => $change) { foreach ($change['hunks'] as $key => $hunk) { list($hunk_data) = execx('tar xfO %s hunks/%s', $path, $hunk['corpus']); $changes[$change_key]['hunks'][$key]['corpus'] = $hunk_data; } } foreach ($changes as $change_key => $change) { $changes[$change_key] = ArcanistDiffChange::newFromDictionary($change); } $obj = new ArcanistBundle(); $obj->changes = $changes; return $obj; } public static function newFromDiff($data) { $obj = new ArcanistBundle(); $parser = new ArcanistDiffParser(); $obj->changes = $parser->parseDiff($data); return $obj; } private function __construct() { } public function writeToDisk($path) { $changes = $this->getChanges(); $change_list = array(); foreach ($changes as $change) { $change_list[] = $change->toDictionary(); } $hunks = array(); foreach ($change_list as $change_key => $change) { foreach ($change['hunks'] as $key => $hunk) { $hunks[] = $hunk['corpus']; $change_list[$change_key]['hunks'][$key]['corpus'] = count($hunks) - 1; } } $blobs = array(); $dir = Filesystem::createTemporaryDirectory(); Filesystem::createDirectory($dir.'/hunks'); Filesystem::createDirectory($dir.'/blobs'); Filesystem::writeFile($dir.'/changes.json', json_encode($change_list)); foreach ($hunks as $key => $hunk) { Filesystem::writeFile($dir.'/hunks/'.$key, $hunk); } foreach ($blobs as $key => $blob) { Filesystem::writeFile($dir.'/blobs/'.$key, $blob); } execx( '(cd %s; tar -czf %s *)', $dir, Filesystem::resolvePath($path)); Filesystem::remove($dir); } public function toUnifiedDiff() { $result = array(); $changes = $this->getChanges(); foreach ($changes as $change) { $old_path = $this->getOldPath($change); $cur_path = $this->getCurrentPath($change); $index_path = $cur_path; if ($index_path === null) { $index_path = $old_path; } $result[] = 'Index: '.$index_path; $result[] = str_repeat('=', 67); if ($old_path === null) { $old_path = '/dev/null'; } if ($cur_path === null) { $cur_path = '/dev/null'; } $result[] = '--- '.$old_path; $result[] = '+++ '.$cur_path; $result[] = $this->buildHunkChanges($change->getHunks()); } return implode("\n", $result)."\n"; } public function toGitPatch() { $result = array(); $changes = $this->getChanges(); foreach ($changes as $change) { $type = $change->getType(); $file_type = $change->getFileType(); if ($file_type == ArcanistDiffChangeType::FILE_DIRECTORY) { // TODO: We should raise a FYI about this, so the user is aware // that we omitted it, if the directory is empty or has permissions // which git can't represent. // Git doesn't support empty directories, so we simply ignore them. If // the directory is nonempty, 'git apply' will create it when processing // the changesets for files inside it. continue; } if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { // Git will apply this in the corresponding MOVE_HERE. continue; } $old_mode = idx($change->getOldProperties(), 'unix:filemode', '100644'); $new_mode = idx($change->getNewProperties(), 'unix:filemode', '100644'); $change_body = $this->buildHunkChanges($change->getHunks()); if ($type == ArcanistDiffChangeType::TYPE_COPY_AWAY) { // TODO: This is only relevant when patching old Differential diffs // which were created prior to arc pruning TYPE_COPY_AWAY for files // with no modifications. if (!strlen($change_body) && ($old_mode == $new_mode)) { continue; } } $old_path = $this->getOldPath($change); $cur_path = $this->getCurrentPath($change); if ($old_path === null) { $old_index = 'a/'.$cur_path; $old_target = '/dev/null'; } else { $old_index = 'a/'.$old_path; $old_target = 'a/'.$old_path; } if ($cur_path === null) { $cur_index = 'b/'.$old_path; $cur_target = '/dev/null'; } else { $cur_index = 'b/'.$cur_path; $cur_target = 'b/'.$cur_path; } $result[] = "diff --git {$old_index} {$cur_index}"; if ($type == ArcanistDiffChangeType::TYPE_ADD) { $result[] = "new file mode {$new_mode}"; } if ($type == ArcanistDiffChangeType::TYPE_COPY_HERE || $type == ArcanistDiffChangeType::TYPE_MOVE_HERE || $type == ArcanistDiffChangeType::TYPE_COPY_AWAY) { if ($old_mode !== $new_mode) { $result[] = "old mode {$old_mode}"; $result[] = "new mode {$new_mode}"; } } if ($type == ArcanistDiffChangeType::TYPE_COPY_HERE) { $result[] = "copy from {$old_path}"; $result[] = "copy to {$cur_path}"; } else if ($type == ArcanistDiffChangeType::TYPE_MOVE_HERE) { $result[] = "rename from {$old_path}"; $result[] = "rename to {$cur_path}"; } else if ($type == ArcanistDiffChangeType::TYPE_DELETE || $type == ArcanistDiffChangeType::TYPE_MULTICOPY) { $old_mode = idx($change->getOldProperties(), 'unix:filemode'); if ($old_mode) { $result[] = "deleted file mode {$old_mode}"; } } $result[] = "--- {$old_target}"; $result[] = "+++ {$cur_target}"; $result[] = $change_body; } return implode("\n", $result)."\n"; } public function getChanges() { return $this->changes; } private function breakHunkIntoSmallHunks(ArcanistDiffHunk $hunk) { $context = 3; $results = array(); $lines = explode("\n", $hunk->getCorpus()); $n = count($lines); $old_offset = $hunk->getOldOffset(); $new_offset = $hunk->getNewOffset(); $ii = 0; $jj = 0; while ($ii < $n) { for ($jj = $ii; $jj < $n && $lines[$jj][0] == ' '; ++$jj) { // Skip lines until we find the first line with changes. } if ($jj >= $n) { break; } $hunk_start = max($jj - $context, 0); $old_lines = 0; $new_lines = 0; $last_change = $jj; for (; $jj < $n; ++$jj) { if ($lines[$jj][0] == ' ') { if ($jj - $last_change > $context) { break; } } else { $last_change = $jj; if ($lines[$jj][0] == '-') { ++$old_lines; } else { ++$new_lines; } } } $hunk_length = min($jj, $n) - $hunk_start; $hunk = new ArcanistDiffHunk(); $hunk->setOldOffset($old_offset + $hunk_start - $ii); $hunk->setNewOffset($new_offset + $hunk_start - $ii); $hunk->setOldLength($hunk_length - $new_lines); $hunk->setNewLength($hunk_length - $old_lines); $corpus = array_slice($lines, $hunk_start, $hunk_length); $corpus = implode("\n", $corpus); $hunk->setCorpus($corpus); $results[] = $hunk; $old_offset += ($jj - $ii) - $new_lines; $new_offset += ($jj - $ii) - $old_lines; $ii = $jj; } return $results; } private function getOldPath(ArcanistDiffChange $change) { $old_path = $change->getOldPath(); $type = $change->getType(); if (!strlen($old_path) || $type == ArcanistDiffChangeType::TYPE_ADD) { $old_path = null; } return $old_path; } private function getCurrentPath(ArcanistDiffChange $change) { $cur_path = $change->getCurrentPath(); $type = $change->getType(); if (!strlen($cur_path) || $type == ArcanistDiffChangeType::TYPE_DELETE || $type == ArcanistDiffChangeType::TYPE_MULTICOPY) { $cur_path = null; } return $cur_path; } private function buildHunkChanges(array $hunks) { $result = array(); foreach ($hunks as $hunk) { $small_hunks = $this->breakHunkIntoSmallHunks($hunk); foreach ($small_hunks as $small_hunk) { $o_off = $small_hunk->getOldOffset(); $o_len = $small_hunk->getOldLength(); $n_off = $small_hunk->getNewOffset(); $n_len = $small_hunk->getNewLength(); $corpus = $small_hunk->getCorpus(); $result[] = "@@ -{$o_off},{$o_len} +{$n_off},{$n_len} @@"; $result[] = $corpus; } } return implode("\n", $result); } } diff --git a/src/parser/diff/ArcanistDiffParser.php b/src/parser/diff/ArcanistDiffParser.php index fec00d3e..9e7a33a8 100644 --- a/src/parser/diff/ArcanistDiffParser.php +++ b/src/parser/diff/ArcanistDiffParser.php @@ -1,795 +1,800 @@ api = $api; return $this; } protected function getRepositoryAPI() { return $this->api; } public function setDetectBinaryFiles($detect) { $this->detectBinaryFiles = $detect; return $this; } public function parseSubversionDiff(ArcanistSubversionAPI $api, $paths) { $this->setRepositoryAPI($api); $diffs = array(); foreach ($paths as $path => $status) { if ($status & ArcanistRepositoryAPI::FLAG_UNTRACKED || $status & ArcanistRepositoryAPI::FLAG_CONFLICT || $status & ArcanistRepositoryAPI::FLAG_MISSING) { unset($paths[$path]); } } $root = null; $from = array(); foreach ($paths as $path => $status) { $change = $this->buildChange($path); if ($status & ArcanistRepositoryAPI::FLAG_ADDED) { $change->setType(ArcanistDiffChangeType::TYPE_ADD); } else if ($status & ArcanistRepositoryAPI::FLAG_DELETED) { $change->setType(ArcanistDiffChangeType::TYPE_DELETE); } else { $change->setType(ArcanistDiffChangeType::TYPE_CHANGE); } $is_dir = is_dir($api->getPath($path)); if ($is_dir) { $change->setFileType(ArcanistDiffChangeType::FILE_DIRECTORY); // We have to go hit the diff even for directories because they may // have property changes or moves, etc. } $is_link = is_link($api->getPath($path)); if ($is_link) { $change->setFileType(ArcanistDiffChangeType::FILE_SYMLINK); } $diff = $api->getRawDiffText($path); if ($diff) { $this->parseDiff($diff); } $info = $api->getSVNInfo($path); if (idx($info, 'Copied From URL')) { if (!$root) { $rinfo = $api->getSVNInfo('.'); $root = $rinfo['URL'].'/'; } $cpath = $info['Copied From URL']; $cpath = substr($cpath, strlen($root)); if ($info['Copied From Rev']) { // The user can "svn cp /path/to/file@12345 x", which pulls a file out // of version history at a specific revision. If we just use the path, // we'll collide with possible changes to that path in the working // copy below. In particular, "svn cp"-ing a path which no longer // exists somewhere in the working copy and then adding that path // gets us to the "origin change type" branches below with a // TYPE_ADD state on the path. To avoid this, append the origin // revision to the path so we'll necessarily generate a new change. // TODO: In theory, you could have an '@' in your path and this could // cause a collision, e.g. two files named 'f' and 'f@12345'. This is // at least somewhat the user's fault, though. if ($info['Copied From Rev'] != $info['Revision']) { $cpath .= '@'.$info['Copied From Rev']; } } $change->setOldPath($cpath); $from[$path] = $cpath; } } foreach ($paths as $path => $status) { $change = $this->buildChange($path); if (empty($from[$path])) { continue; } if (empty($this->changes[$from[$path]])) { if ($change->getType() == ArcanistDiffChangeType::TYPE_COPY_HERE) { // If the origin path wasn't changed (or isn't included in this diff) // and we only copied it, don't generate a changeset for it. This // keeps us out of trouble when we go to 'arc commit' and need to // figure out which files should be included in the commit list. continue; } } $origin = $this->buildChange($from[$path]); $origin->addAwayPath($change->getCurrentPath()); $type = $origin->getType(); switch ($type) { case ArcanistDiffChangeType::TYPE_MULTICOPY: case ArcanistDiffChangeType::TYPE_COPY_AWAY: break; case ArcanistDiffChangeType::TYPE_DELETE: $origin->setType(ArcanistDiffChangeType::TYPE_MOVE_AWAY); break; case ArcanistDiffChangeType::TYPE_MOVE_AWAY: $origin->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); break; case ArcanistDiffChangeType::TYPE_CHANGE: $origin->setType(ArcanistDiffChangeType::TYPE_COPY_AWAY); break; default: throw new Exception("Bad origin state {$type}."); } $type = $origin->getType(); switch ($type) { case ArcanistDiffChangeType::TYPE_MULTICOPY: case ArcanistDiffChangeType::TYPE_MOVE_AWAY: $change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE); break; case ArcanistDiffChangeType::TYPE_COPY_AWAY: $change->setType(ArcanistDiffChangeType::TYPE_COPY_HERE); break; default: throw new Exception("Bad origin state {$type}."); } } return $this->changes; } public function parseDiff($diff) { $this->didStartParse($diff); if ($this->getLine() === null) { $this->didFailParse("Can't parse an empty diff!"); } do { $patterns = array( // This is a normal SVN text change, probably from "svn diff". '(?PIndex): (?P.+)', // This is an SVN property change, probably from "svn diff". '(?PProperty changes on): (?P.+)', // This is a git commit message, probably from "git show". '(?Pcommit) (?P[a-f0-9]+)', // This is a git diff, probably from "git show" or "git diff". '(?Pdiff --git) a/(?P.+) b/(?P.+)', // This is a unified diff, probably from "diff -u" or synthetic diffing. '(?P---) (?P.+)\s+\d{4}-\d{2}-\d{2}.*', '(?PBinary) files '. '(?P.+)\s+\d{4}-\d{2}-\d{2} and '. '(?P.+)\s+\d{4}-\d{2}-\d{2} differ.*', ); $ok = false; $line = $this->getLine(); $match = null; foreach ($patterns as $pattern) { $ok = preg_match('@^'.$pattern.'$@', $line, $match); if ($ok) { break; } } if (!$ok) { $this->didFailParse( "Expected a hunk header, like 'Index: /path/to/file.ext' (svn), ". "'Property changes on: /path/to/file.ext' (svn properties), ". "'commit 59bcc3ad6775562f845953cf01624225' (git show), ". "'diff --git' (git diff), or '--- filename' (unified diff)."); } $change = $this->buildChange(idx($match, 'cur')); if (isset($match['old'])) { $change->setOldPath($match['old']); } if (isset($match['hash'])) { $change->setCommitHash($match['hash']); } if (isset($match['binary'])) { $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); $line = $this->nextNonemptyLine(); continue; } $line = $this->nextLine(); switch ($match['type']) { case 'Index': $this->parseIndexHunk($change); break; case 'Property changes on': $this->parsePropertyHunk($change); break; case 'diff --git': $this->setIsGit(true); $this->parseIndexHunk($change); break; case 'commit': $this->setIsGit(true); $this->parseCommitMessage($change); break; case '---': $ok = preg_match( '@^(?:\+\+\+) (.*)\s+\d{4}-\d{2}-\d{2}.*$@', $line, $match); if (!$ok) { $this->didFailParse("Expected '+++ filename' in unified diff."); } $change->setCurrentPath($match[1]); $line = $this->nextLine(); $this->parseChangeset($change); break; default: $this->didFailParse("Unknown diff type."); } } while ($this->getLine() !== null); $this->didFinishParse(); return $this->changes; } protected function parseCommitMessage(ArcanistDiffChange $change) { $change->setType(ArcanistDiffChangeType::TYPE_MESSAGE); $message = array(); $line = $this->getLine(); if (preg_match('/^Merge: /', $line)) { $this->nextLine(); } $line = $this->getLine(); if (!preg_match('/^Author: /', $line)) { $this->didFailParse("Expected 'Author:'."); } $line = $this->nextLine(); if (!preg_match('/^Date: /', $line)) { $this->didFailParse("Expected 'Date:'."); } while (($line = $this->nextLine()) !== null) { if (strlen($line) && $line[0] != ' ') { break; } // Strip leading spaces from Git commit messages. $message[] = substr($line, 4); } $message = rtrim(implode("\n", $message)); $change->setMetadata('message', $message); } /** * Parse an SVN property change hunk. These hunks are ambiguous so just sort * of try to get it mostly right. It's entirely possible to foil this parser * (or any other parser) with a carefully constructed property change. */ protected function parsePropertyHunk(ArcanistDiffChange $change) { $line = $this->getLine(); if (!preg_match('/^_+$/', $line)) { $this->didFailParse("Expected '______________________'."); } $line = $this->nextLine(); while ($line !== null) { $done = preg_match('/^(Index|Property changes on):/', $line); if ($done) { break; } $matches = null; $ok = preg_match('/^(Modified|Added|Deleted): (.*)$/', $line, $matches); if (!$ok) { $this->didFailParse("Expected 'Added', 'Deleted', or 'Modified'."); } $op = $matches[1]; $prop = $matches[2]; list($old, $new) = $this->parseSVNPropertyChange($op, $prop); if ($old !== null) { $change->setOldProperty($prop, $old); } if ($new !== null) { $change->setNewProperty($prop, $new); } $line = $this->getLine(); } } private function parseSVNPropertyChange($op, $prop) { $old = array(); $new = array(); $target = null; $line = $this->nextLine(); while ($line !== null) { $done = preg_match( '/^(Modified|Added|Deleted|Index|Property changes on):/', $line); if ($done) { break; } $trimline = ltrim($line); if ($trimline && $trimline[0] == '+') { if ($op == 'Deleted') { $this->didFailParse('Unexpected "+" section in property deletion.'); } $target = 'new'; $line = substr($trimline, 2); } else if ($trimline && $trimline[0] == '-') { if ($op == 'Added') { $this->didFailParse('Unexpected "-" section in property addition.'); } $target = 'old'; $line = substr($trimline, 2); } else if (!strncmp($trimline, 'Merged', 6)) { if ($op == 'Added') { $target = 'new'; } else { // These can appear on merges. No idea how to interpret this (unclear // what the old / new values are) and it's of dubious usefulness so // just throw it away until someone complains. $target = null; } $line = $trimline; } if ($target == 'new') { $new[] = $line; } else if ($target == 'old') { $old[] = $line; } $line = $this->nextLine(); } $old = rtrim(implode("\n", $old)); $new = rtrim(implode("\n", $new)); if (!strlen($old)) { $old = null; } if (!strlen($new)) { $new = null; } return array($old, $new); } protected function setIsGit($git) { if ($this->isGit !== null && $this->isGit != $git) { throw new Exception("Git status has changed!"); } $this->isGit = $git; return $this; } protected function getIsGit() { return $this->isGit; } protected function parseIndexHunk(ArcanistDiffChange $change) { $is_git = $this->getIsGit(); $line = $this->getLine(); if ($is_git) { do { $patterns = array( '(?Pnew) file mode (?P\d+)', '(?Pdeleted) file mode (?P\d+)', // These occur when someone uses `chmod` on a file. 'old mode (?P\d+)', 'new mode (?P\d+)', // These occur when you `mv` a file and git figures it out. 'similarity index ', 'rename from (?P.*)', '(?Prename) to (?P.*)', 'copy from (?P.*)', '(?Pcopy) to (?P.*)' ); $ok = false; $match = null; foreach ($patterns as $pattern) { $ok = preg_match('@^'.$pattern.'@', $line, $match); if ($ok) { break; } } if (!$ok) { if ($line === null || preg_match('/^(diff --git|commit) /', $line)) { // In this case, there are ONLY file mode changes, or this is a // pure move. return; } break; } if (!empty($match['oldmode'])) { $change->setOldProperty('unix:filemode', $match['oldmode']); } if (!empty($match['newmode'])) { $change->setNewProperty('unix:filemode', $match['newmode']); } if (!empty($match['deleted'])) { $change->setType(ArcanistDiffChangeType::TYPE_DELETE); } if (!empty($match['new'])) { $change->setType(ArcanistDiffChangeType::TYPE_ADD); } if (!empty($match['old'])) { $change->setOldPath($match['old']); } if (!empty($match['cur'])) { $change->setCurrentPath($match['cur']); } if (!empty($match['copy'])) { $change->setType(ArcanistDiffChangeType::TYPE_COPY_HERE); $old = $this->buildChange($change->getOldPath()); $type = $old->getType(); if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else { $old->setType(ArcanistDiffChangeType::TYPE_COPY_AWAY); } $old->addAwayPath($change->getCurrentPath()); } if (!empty($match['move'])) { $change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE); $old = $this->buildChange($change->getOldPath()); $type = $old->getType(); if ($type == ArcanistDiffChangeType::TYPE_MULTICOPY) { // Great, no change. } else if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else if ($type == ArcanistDiffChangeType::TYPE_COPY_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else { $old->setType(ArcanistDiffChangeType::TYPE_MOVE_AWAY); } $old->addAwayPath($change->getCurrentPath()); } $line = $this->nextNonemptyLine(); } while (true); } $line = $this->getLine(); $ok = preg_match('/^=+$/', $line) || ($is_git && preg_match('/^index .*$/', $line)); if (!$ok) { if ($is_git) { $this->didFailParse( "Expected 'index af23f...a98bc' header line."); } else { $this->didFailParse( "Expected '==========================' divider line."); } } // Adding an empty file in SVN can produce an empty line here. $line = $this->nextNonemptyLine(); // If there are files with only whitespace changes and -b or -w are // supplied as command-line flags to `diff', svn and git both produce // changes without any body. if ($line === null || preg_match( '/^(Index:|Property changes on:|diff --git|commit) /', $line)) { return; } $is_binary_add = preg_match( '/^Cannot display: file marked as a binary type.$/', $line); if ($is_binary_add) { $this->nextLine(); // Cannot display: file marked as a binary type. $this->nextNonemptyLine(); // svn:mime-type = application/octet-stream $this->markBinary($change); return; } // We can get this in git, or in SVN when a file exists in the repository // WITHOUT a binary mime-type and is changed and given a binary mime-type. $is_binary_diff = preg_match( '/^Binary files .* and .* differ$/', $line); if ($is_binary_diff) { $this->nextNonemptyLine(); // Binary files x and y differ $this->markBinary($change); return; } if ($is_git) { // "git diff -b" ignores whitespace, but has an empty hunk target if (preg_match('@^diff --git a/.*$@', $line)) { $this->nextLine(); return null; } } $old_file = $this->parseHunkTarget(); $new_file = $this->parseHunkTarget(); $change->setOldPath($old_file); $this->parseChangeset($change); } protected function parseHunkTarget() { $line = $this->getLine(); $matches = null; $ok = preg_match( '@^[-+]{3} (?:[ab]/)?(?P.*?)(?:\s*\(.*\))?$@', $line, $matches); if (!$ok) { $this->didFailParse( "Expected hunk target '+++ path/to/file.ext (revision N)'."); } $this->nextLine(); return $matches['path']; } protected function markBinary(ArcanistDiffChange $change) { $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); return $this; } protected function parseChangeset(ArcanistDiffChange $change) { $all_changes = array(); do { $hunk = new ArcanistDiffHunk(); $line = $this->getLine(); $real = array(); // In the case where only one line is changed, the length is omitted. // The final group is for git, which appends a guess at the function // context to the diff. $matches = null; $ok = preg_match( '/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*?)?$/U', $line, $matches); if (!$ok) { $this->didFailParse("Expected hunk header '@@ -NN,NN +NN,NN @@'."); } $hunk->setOldOffset($matches[1]); $hunk->setNewOffset($matches[3]); // Cover for the cases where length wasn't present (implying one line). $old_len = idx($matches, 2); if (!strlen($old_len)) { $old_len = 1; } $new_len = idx($matches, 4); if (!strlen($new_len)) { $new_len = 1; } $hunk->setOldLength($old_len); $hunk->setNewLength($new_len); $add = 0; $del = 0; $advance = false; while ((($line = $this->nextLine()) !== null)) { if (strlen($line)) { $char = $line[0]; } else { $char = '~'; } switch ($char) { case '\\': if (!preg_match('@\\ No newline at end of file@', $line)) { $this->didFailParse( "Expected '\ No newline at end of file'."); } if ($new_len) { $hunk->setIsMissingOldNewline(true); } else { $hunk->setIsMissingNewNewline(true); } if (!$new_len) { $advance = true; break 2; } break; case '+': if (!$new_len) { break 2; } ++$add; --$new_len; $real[] = $line; break; case '-': if (!$old_len) { break 2; } ++$del; --$old_len; $real[] = $line; break; case ' ': if (!$old_len && !$new_len) { break 2; } --$old_len; --$new_len; $real[] = $line; break; case '~': $advance = true; break 2; default: break 2; } } if ($old_len != 0 || $new_len != 0) { $this->didFailParse("Found the wrong number of hunk lines."); } $corpus = implode("\n", $real); $is_binary = false; if ($this->detectBinaryFiles) { $is_binary = preg_match('/([^\x09\x0A\x20-\x7E]+)/', $corpus); } if ($is_binary) { // SVN happily treats binary files which aren't marked with the right // mime type as text files. Detect that junk here and mark the file // binary. We'll catch stuff with unicode too, but that's verboten // anyway. If there are too many false positives with this we might // need to make it threshold-triggered instead of triggering on any // unprintable byte. $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); } else { $hunk->setCorpus($corpus); $hunk->setAddLines($add); $hunk->setDelLines($del); $change->addHunk($hunk); } if ($advance) { $line = $this->nextNonemptyLine(); } } while (preg_match('/^@@ /', $line)); } protected function buildChange($path = null) { $change = null; if ($path !== null) { if (!empty($this->changes[$path])) { return $this->changes[$path]; } } $change = new ArcanistDiffChange(); if ($path !== null) { $change->setCurrentPath($path); $this->changes[$path] = $change; } else { $this->changes[] = $change; } return $change; } protected function didStartParse($text) { // TODO: Removed an fb_utf8ize() call here. -epriestley // Eat leading whitespace. This may happen if the first change in the diff // is an SVN property change. $text = ltrim($text); $this->text = explode("\n", $text); $this->line = 0; } protected function getLine() { if ($this->text === null) { throw new Exception("Not parsing!"); } if (isset($this->text[$this->line])) { return $this->text[$this->line]; } return null; } protected function nextLine() { $this->line++; return $this->getLine(); } protected function nextNonemptyLine() { while (($line = $this->nextLine()) !== null) { if (strlen(trim($line)) !== 0) { break; } } return $this->getLine(); } protected function didFinishParse() { $this->text = null; } protected function didFailParse($message) { $min = max(0, $this->line - 3); $max = min($this->line + 3, count($this->text) - 1); $context = ''; for ($ii = $min; $ii <= $max; $ii++) { $context .= sprintf( "%8.8s %s\n", ($ii == $this->line) ? '>>> ' : '', $this->text[$ii]); } $message = "Parse Exception: {$message}\n\n{$context}\n"; throw new Exception($message); } } diff --git a/src/parser/diff/__tests__/ArcanistDiffParserTestCase.php b/src/parser/diff/__tests__/ArcanistDiffParserTestCase.php index e67774e8..b69ba362 100644 --- a/src/parser/diff/__tests__/ArcanistDiffParserTestCase.php +++ b/src/parser/diff/__tests__/ArcanistDiffParserTestCase.php @@ -1,458 +1,463 @@ parseDiff($root.$file); } } private function parseDiff($diff_file) { $contents = Filesystem::readFile($diff_file); $file = basename($diff_file); $parser = new ArcanistDiffParser(); $changes = $parser->parseDiff($contents); switch ($file) { case 'basic-missing-both-newlines-plus.udiff': case 'basic-missing-both-newlines.udiff': case 'basic-missing-new-newline-plus.udiff': case 'basic-missing-new-newline.udiff': case 'basic-missing-old-newline-plus.udiff': case 'basic-missing-old-newline.udiff': $expect_old = strpos($file, '-old-') || strpos($file, '-both-'); $expect_new = strpos($file, '-new-') || strpos($file, '-both-'); $expect_two = strpos($file, '-plus'); $this->assertEqual(count($changes), $expect_two ? 2 : 1); $change = reset($changes); $this->assertEqual(true, $change !== null); $hunks = $change->getHunks(); $this->assertEqual(1, count($hunks)); $hunk = reset($hunks); $this->assertEqual((bool)$expect_old, $hunk->getIsMissingOldNewline()); $this->assertEqual((bool)$expect_new, $hunk->getIsMissingNewNewline()); break; case 'basic-binary.udiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); break; case 'basic-multi-hunk.udiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $hunks = $change->getHunks(); $this->assertEqual(4, count($hunks)); $this->assertEqual('right', $change->getCurrentPath()); $this->assertEqual('left', $change->getOldPath()); break; case 'basic-multi-hunk-content.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $hunks = $change->getHunks(); $this->assertEqual(2, count($hunks)); $there_is_a_literal_trailing_space_here = ' '; $corpus_0 = <<assertEqual( $corpus_0, $hunks[0]->getCorpus()); $this->assertEqual( $corpus_1, $hunks[1]->getCorpus()); break; case 'svn-ignore-whitespace-only.svndiff': $this->assertEqual(2, count($changes)); $hunks = reset($changes)->getHunks(); $this->assertEqual(0, count($hunks)); break; case 'svn-property-add.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $hunks = reset($changes)->getHunks(); $this->assertEqual(1, count($hunks)); $this->assertEqual( array( 'duck' => 'quack', ), $change->getNewProperties() ); break; case 'svn-property-modify.svndiff': $this->assertEqual(2, count($changes)); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( array( 'svn:ignore' => '*.phpz', ), $change->getOldProperties() ); $this->assertEqual( array( 'svn:ignore' => '*.php', ), $change->getNewProperties() ); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( array( 'svn:special' => '*', ), $change->getOldProperties() ); $this->assertEqual( array( 'svn:special' => 'moo', ), $change->getNewProperties() ); break; case 'svn-property-delete.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( $change->getOldProperties(), array( 'svn:special' => '*', )); $this->assertEqual( array( ), $change->getNewProperties()); break; case 'svn-property-merged.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual(count($change->getHunks()), 0); $this->assertEqual( $change->getOldProperties(), array()); $this->assertEqual( $change->getNewProperties(), array()); break; case 'svn-property-merge.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual(count($change->getHunks()), 0); $this->assertEqual( $change->getOldProperties(), array( )); $this->assertEqual( $change->getNewProperties(), array( 'svn:mergeinfo' => <<assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( array( 'svn:mime-type' => 'application/octet-stream', ), $change->getNewProperties() ); break; case 'svn-binary-diff.svndiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); $this->assertEqual(count($change->getHunks()), 0); break; case 'git-delete-file.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_DELETE, $change->getType()); $this->assertEqual( 'scripts/intern/test/testfile2', $change->getCurrentPath()); $this->assertEqual(1, count($change->getHunks())); break; case 'git-binary-change.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::FILE_BINARY, $change->getFileType()); $this->assertEqual(0, count($change->getHunks())); break; case 'git-filemode-change.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual(1, count($change->getHunks())); $this->assertEqual( array( 'unix:filemode' => '100644', ), $change->getOldProperties() ); $this->assertEqual( array( 'unix:filemode' => '100755', ), $change->getNewProperties() ); break; case 'git-filemode-change-only.gitdiff': $this->assertEqual(count($changes), 2); $change = reset($changes); $this->assertEqual(count($change->getHunks()), 0); $this->assertEqual( array( 'unix:filemode' => '100644', ), $change->getOldProperties() ); $this->assertEqual( array( 'unix:filemode' => '100755', ), $change->getNewProperties() ); break; case 'svn-empty-file.svndiff': $this->assertEqual(2, count($changes)); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); break; case 'git-ignore-whitespace-only.gitdiff': $this->assertEqual(count($changes), 2); $change = array_shift($changes); $this->assertEqual(count($change->getHunks()), 0); $this->assertEqual( $change->getOldPath(), 'scripts/intern/test/testfile2'); $this->assertEqual( $change->getCurrentPath(), 'scripts/intern/test/testfile2'); $change = array_shift($changes); $this->assertEqual(count($change->getHunks()), 1); $this->assertEqual( $change->getOldPath(), 'scripts/intern/test/testfile3'); $this->assertEqual( $change->getCurrentPath(), 'scripts/intern/test/testfile3'); break; case 'git-move.gitdiff': case 'git-move-edit.gitdiff': case 'git-move-plus.gitdiff': $extra_changeset = (bool)strpos($file, '-plus'); $has_hunk = (bool)strpos($file, '-edit'); $this->assertEqual($extra_changeset ? 3 : 2, count($changes)); $change = array_shift($changes); $this->assertEqual($has_hunk ? 1 : 0, count($change->getHunks())); $this->assertEqual( $change->getType(), ArcanistDiffChangeType::TYPE_MOVE_HERE); $target = $change; $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_MOVE_AWAY, $change->getType() ); $this->assertEqual( $change->getCurrentPath(), $target->getOldPath()); $this->assertEqual( true, in_array($target->getCurrentPath(), $change->getAwayPaths())); break; case 'git-merge-header.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_MESSAGE, $change->getType()); $this->assertEqual( '501f6d519703458471dbea6284ec5f49d1408598', $change->getCommitHash()); break; case 'git-new-file.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_ADD, $change->getType()); break; case 'git-copy.gitdiff': $this->assertEqual(2, count($changes)); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_COPY_HERE, $change->getType()); $this->assertEqual( 'flib/intern/widgets/ui/UIWidgetRSSBox.php', $change->getCurrentPath()); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_COPY_AWAY, $change->getType()); $this->assertEqual( 'lib/display/intern/ui/widget/UIWidgetRSSBox.php', $change->getCurrentPath()); break; case 'git-copy-plus.gitdiff': $this->assertEqual(2, count($changes)); $change = array_shift($changes); $this->assertEqual(3, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_COPY_HERE, $change->getType()); $this->assertEqual( 'flib/intern/widgets/ui/UIWidgetGraphConnect.php', $change->getCurrentPath()); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( ArcanistDiffChangeType::TYPE_COPY_AWAY, $change->getType()); $this->assertEqual( 'lib/display/intern/ui/widget/UIWidgetLunchtime.php', $change->getCurrentPath()); break; case 'svn-property-multiline.svndiff': $this->assertEqual(1, count($changes)); $change = array_shift($changes); $this->assertEqual(0, count($change->getHunks())); $this->assertEqual( array( 'svn:ignore' => 'tags', ), $change->getOldProperties() ); $this->assertEqual( array( 'svn:ignore' => "tags\nasdf\nlol\nwhat", ), $change->getNewProperties() ); break; case 'git-commit.gitdiff': $this->assertEqual(1, count($changes)); $change = reset($changes); $this->assertEqual( ArcanistDiffChangeType::TYPE_MESSAGE, $change->getType()); $this->assertEqual( '76e2f1339c298c748aa0b52030799ed202a6537b', $change->getCommitHash()); $this->assertEqual( <<. I tested most of these calls, but there were some that I didn't know how to reach, so if you are one of the owners of this code, please test your feature in my sandbox: www.ngao.devrs013.facebook.com @brosenthal, I removed some logic that was setting a disabled state on a UIActionButton, which is actually a no-op. Reviewed By: brosenthal Other Commenters: sparker, egiovanola Test Plan: www.ngao.devrs013.facebook.com Explicitly tested: * ads creation flow (add keyword) * ads manager (conversion tracking) * help center (create a discussion) * new user wizard (next step button) Revert: OK DiffCamp Revision: 94064 git-svn-id: svn+ssh://tubbs/svnroot/tfb/trunk/www@223593 2c7ba8d8 EOTEXT , $change->getMetadata('message') ); break; default: throw new Exception("No test block for diff file {$diff_file}."); break; } } } diff --git a/src/parser/diff/change/ArcanistDiffChange.php b/src/parser/diff/change/ArcanistDiffChange.php index d45402d7..ff7ced1d 100644 --- a/src/parser/diff/change/ArcanistDiffChange.php +++ b/src/parser/diff/change/ArcanistDiffChange.php @@ -1,224 +1,229 @@ hunks as $hunk) { $hunks[] = $hunk->toDictionary(); } return array( 'metadata' => $this->metadata, 'oldPath' => $this->oldPath, 'currentPath' => $this->currentPath, 'awayPaths' => $this->awayPaths, 'oldProperties' => $this->oldProperties, 'newProperties' => $this->newProperties, 'type' => $this->type, 'fileType' => $this->fileType, 'commitHash' => $this->commitHash, 'hunks' => $hunks, ); } public static function newFromDictionary(array $dict) { $hunks = array(); foreach ($dict['hunks'] as $hunk) { $hunks[] = ArcanistDiffHunk::newFromDictionary($hunk); } $obj = new ArcanistDiffChange(); $obj->metdadata = $dict['metadata']; $obj->oldPath = $dict['oldPath']; $obj->currentPath = $dict['currentPath']; // TODO: The backend is shipping down some bogus data, e.g. diff 199453. // Should probably clean this up. $obj->awayPaths = nonempty($dict['awayPaths'], array()); $obj->oldProperties = nonempty($dict['oldProperties'], array()); $obj->newProperties = nonempty($dict['newProperties'], array()); $obj->type = $dict['type']; $obj->fileType = $dict['fileType']; $obj->commitHash = $dict['commitHash']; $obj->hunks = $hunks; return $obj; } public function getChangedLines($type) { $lines = array(); foreach ($this->hunks as $hunk) { $lines += $hunk->getChangedLines($type); } return $lines; } public function getAllMetadata() { return $this->metadata; } public function setMetadata($key, $value) { $this->metadata[$key] = $value; return $this; } public function getMetadata($key) { return idx($this->metadata, $key); } public function setCommitHash($hash) { $this->commitHash = $hash; return $this; } public function getCommitHash() { return $this->commitHash; } public function addAwayPath($path) { $this->awayPaths[] = $path; return $this; } public function getAwayPaths() { return $this->awayPaths; } public function setFileType($type) { $this->fileType = $type; return $this; } public function getFileType() { return $this->fileType; } public function setType($type) { $this->type = $type; return $this; } public function getType() { return $this->type; } public function setOldProperty($key, $value) { $this->oldProperties[$key] = $value; return $this; } public function setNewProperty($key, $value) { $this->newProperties[$key] = $value; return $this; } public function getOldProperties() { return $this->oldProperties; } public function getNewProperties() { return $this->newProperties; } public function setCurrentPath($path) { $this->currentPath = $this->filterPath($path); return $this; } public function getCurrentPath() { return $this->currentPath; } public function setOldPath($path) { $this->oldPath = $this->filterPath($path); return $this; } public function getOldPath() { return $this->oldPath; } public function addHunk(ArcanistDiffHunk $hunk) { $this->hunks[] = $hunk; return $this; } public function getHunks() { return $this->hunks; } public function convertToBinaryChange() { $this->hunks = array(); $this->setFileType(ArcanistDiffChangeType::FILE_BINARY); return $this; } protected function filterPath($path) { if ($path == '/dev/null') { return null; } return $path; } public function renderTextSummary() { $type = $this->getType(); $file = $this->getFileType(); $char = ArcanistDiffChangeType::getSummaryCharacterForChangeType($type); $attr = ArcanistDiffChangeType::getShortNameForFileType($file); if ($attr) { $attr = '('.$attr.')'; } $summary = array(); $summary[] = sprintf( "%s %5.5s %s", $char, $attr, $this->getCurrentPath()); if (ArcanistDiffChangeType::isOldLocationChangeType($type)) { foreach ($this->getAwayPaths() as $path) { $summary[] = ' to: '.$path; } } if (ArcanistDiffChangeType::isNewLocationChangeType($type)) { $summary[] = ' from: '.$this->getOldPath(); } return implode("\n", $summary); } } diff --git a/src/parser/diff/changetype/ArcanistDiffChangeType.php b/src/parser/diff/changetype/ArcanistDiffChangeType.php index 3eceddbf..be68a5b6 100644 --- a/src/parser/diff/changetype/ArcanistDiffChangeType.php +++ b/src/parser/diff/changetype/ArcanistDiffChangeType.php @@ -1,124 +1,129 @@ 'A', self::TYPE_CHANGE => 'M', self::TYPE_DELETE => 'D', self::TYPE_MOVE_AWAY => 'V', self::TYPE_COPY_AWAY => 'P', self::TYPE_MOVE_HERE => 'V', self::TYPE_COPY_HERE => 'P', self::TYPE_MULTICOPY => 'P', self::TYPE_MESSAGE => 'Q', self::TYPE_CHILD => '@', ); return idx($types, coalesce($type, '?'), '~'); } public static function getShortNameForFileType($type) { static $names = array( self::FILE_TEXT => null, self::FILE_DIRECTORY => 'dir', self::FILE_IMAGE => 'img', self::FILE_BINARY => 'bin', self::FILE_SYMLINK => 'sym', ); return idx($names, coalesce($type, '?'), '???'); } public static function isOldLocationChangeType($type) { static $types = array( ArcanistDiffChangeType::TYPE_MOVE_AWAY => true, ArcanistDiffChangeType::TYPE_COPY_AWAY => true, ArcanistDiffChangeType::TYPE_MULTICOPY => true, ); return isset($types[$type]); } public static function isNewLocationChangeType($type) { static $types = array( ArcanistDiffChangeType::TYPE_MOVE_HERE => true, ArcanistDiffChangeType::TYPE_COPY_HERE => true, ); return isset($types[$type]); } public static function isDeleteChangeType($type) { static $types = array( ArcanistDiffChangeType::TYPE_DELETE => true, ArcanistDiffChangeType::TYPE_MOVE_AWAY => true, ArcanistDiffChangeType::TYPE_MULTICOPY => true, ); return isset($types[$type]); } public static function isCreateChangeType($type) { static $types = array( ArcanistDiffChangeType::TYPE_ADD => true, ArcanistDiffChangeType::TYPE_COPY_HERE => true, ArcanistDiffChangeType::TYPE_MOVE_HERE => true, ); return isset($types[$type]); } public static function isModifyChangeType($type) { static $types = array( ArcanistDiffChangeType::TYPE_CHANGE => true, ); return isset($types[$type]); } public static function getFullNameForChangeType($type) { static $types = array( self::TYPE_ADD => 'Added', self::TYPE_CHANGE => 'Modified', self::TYPE_DELETE => 'Deleted', self::TYPE_MOVE_AWAY => 'Moved Away', self::TYPE_COPY_AWAY => 'Copied Away', self::TYPE_MOVE_HERE => 'Moved Here', self::TYPE_COPY_HERE => 'Copied Here', self::TYPE_MULTICOPY => 'Deleted After Multiple Copy', self::TYPE_MESSAGE => 'Commit Message', self::TYPE_CHILD => 'Contents Modified', ); return idx($types, coalesce($type, '?'), 'Unknown'); } } diff --git a/src/parser/diff/hunk/ArcanistDiffHunk.php b/src/parser/diff/hunk/ArcanistDiffHunk.php index 031f34fd..4b8af211 100644 --- a/src/parser/diff/hunk/ArcanistDiffHunk.php +++ b/src/parser/diff/hunk/ArcanistDiffHunk.php @@ -1,188 +1,189 @@ $this->oldOffset, 'newOffset' => $this->newOffset, 'oldLength' => $this->oldLength, 'newLength' => $this->newLength, 'addLines' => $this->addLines, 'delLines' => $this->delLines, 'isMissingOldNewline' => $this->isMissingOldNewline, 'isMissingNewNewline' => $this->isMissingNewNewline, 'corpus' => $this->corpus, ); } public static function newFromDictionary(array $dict) { $obj = new ArcanistDiffHunk(); $obj->oldOffset = $dict['oldOffset']; $obj->newOffset = $dict['newOffset']; $obj->oldLength = $dict['oldLength']; $obj->newLength = $dict['newLength']; $obj->addLines = $dict['addLines']; $obj->delLines = $dict['delLines']; $obj->isMissingOldNewline = $dict['isMissingOldNewline']; $obj->isMissingNewNewline = $dict['isMissingNewNewline']; $obj->corpus = $dict['corpus']; return $obj; } public function getChangedLines($type) { $old_map = array(); $new_map = array(); $cover_map = array(); $oline = $this->getOldOffset(); $nline = $this->getNewOffset(); foreach (explode("\n", $this->getCorpus()) as $line) { $char = strlen($line) ? $line[0] : '~'; switch ($char) { case '-': $old_map[$oline] = true; $cover_map[$oline] = true; ++$oline; break; case '+': $new_map[$nline] = true; if ($oline > 1) { $cover_map[$oline - 1] = true; } $cover_map[$oline] = true; ++$nline; break; default: ++$oline; ++$nline; break; } } switch ($type) { case 'new': return $new_map; case 'old': return $old_map; case 'cover': return $cover_map; default: throw new Exception("Unknown line change type '{$type}'."); } } public function setOldOffset($old_offset) { $this->oldOffset = $old_offset; return $this; } public function getOldOffset() { return $this->oldOffset; } public function setNewOffset($new_offset) { $this->newOffset = $new_offset; return $this; } public function getNewOffset() { return $this->newOffset; } public function setOldLength($old_length) { $this->oldLength = $old_length; return $this; } public function getOldLength() { return $this->oldLength; } public function setNewLength($new_length) { $this->newLength = $new_length; return $this; } public function getNewLength() { return $this->newLength; } public function setAddLines($add_lines) { $this->addLines = $add_lines; return $this; } public function getAddLines() { return $this->addLines; } public function setDelLines($del_lines) { $this->delLines = $del_lines; return $this; } public function getDelLines() { return $this->delLines; } public function setCorpus($corpus) { $this->corpus = $corpus; return $this; } public function getCorpus() { return $this->corpus; } public function setIsMissingOldNewline($missing) { $this->isMissingOldNewline = (bool)$missing; return $this; } public function getIsMissingOldNewline() { return $this->isMissingOldNewline; } public function setIsMissingNewNewline($missing) { $this->isMissingNewNewline = (bool)$missing; return $this; } public function getIsMissingNewNewline() { return $this->isMissingNewNewline; } } diff --git a/src/staticanalysis/parsers/phutilmodule/PhutilModuleRequirements.php b/src/parser/phutilmodule/PhutilModuleRequirements.php similarity index 98% rename from src/staticanalysis/parsers/phutilmodule/PhutilModuleRequirements.php rename to src/parser/phutilmodule/PhutilModuleRequirements.php index 0b5f37b9..f6bbcbb7 100644 --- a/src/staticanalysis/parsers/phutilmodule/PhutilModuleRequirements.php +++ b/src/parser/phutilmodule/PhutilModuleRequirements.php @@ -1,171 +1,176 @@ array(), 'interface' => array(), 'function' => array(), ); protected $requires = array( 'class' => array(), 'interface' => array(), 'function' => array(), 'source' => array(), 'module' => array(), ); protected $declares = array( 'class' => array(), 'interface' => array(), 'function' => array(), 'source' => array(), ); protected $chain = array( ); protected $currentFile; protected $messages = array( ); public function setCurrentFile($current_file) { $this->currentFile = $current_file; return $this; } protected function getCurrentFile() { return $this->currentFile; } protected function getWhere(XHPASTNode $where) { return $this->getCurrentFile().':'.$where->getOffset(); } public function addClassDeclaration(XHPASTNode $where, $name) { return $this->addDeclaration('class', $where, $name); } public function addFunctionDeclaration(XHPASTNode $where, $name) { return $this->addDeclaration('function', $where, $name); } public function addInterfaceDeclaration(XHPASTNode $where, $name) { return $this->addDeclaration('interface', $where, $name); } public function addSourceDeclaration($name) { $this->declares['source'][$name] = true; return $this; } protected function addDeclaration($type, XHPASTNode $where, $name) { $this->declares[$type][$name] = $this->getWhere($where); return $this; } protected function addDependency($type, XHPASTNode $where, $name) { if (isset($this->builtins[$type][$name])) { return $this; } if (empty($this->requires[$type][$name])) { $this->requires[$type][$name] = array(); } $this->requires[$type][$name][] = $this->getWhere($where); return $this; } public function addClassDependency($child, XHPASTNode $where, $name) { if ($child !== null) { if (empty($this->builtins['class'][$name])) { $this->chain['class'][$child] = $name; } } return $this->addDependency('class', $where, $name); } public function addFunctionDependency(XHPASTNode $where, $name) { return $this->addDependency('function', $where, $name); } public function addInterfaceDependency($child, XHPASTNode $where, $name) { if ($child !== null) { if (empty($this->builtins['interface'][$name])) { $this->chain['interface'][$child][] = $name; } } return $this->addDependency('interface', $where, $name); } public function addSourceDependency(XHPASTNode $where, $name) { return $this->addDependency('source', $where, $name); } public function addModuleDependency(XHPASTNode $where, $name) { return $this->addDependency('module', $where, $name); } public function addBuiltins(array $builtins) { foreach ($builtins as $type => $symbol_set) { $this->builtins[$type] += $symbol_set; } return $this; } public function addRawLint($code, $message) { $this->messages[] = array( null, null, $code, $message); return $this; } public function addLint(XHPASTNode $where, $text, $code, $message) { $this->messages[] = array( $this->getWhere($where), $text, $code, $message); return $this; } public function toDictionary() { // Remove all dependencies on things which we declare since they're never // useful and guaranteed to be satisfied. foreach ($this->declares as $type => $things) { if ($type == 'source') { // Source is treated specially since we only reconcile it locally. continue; } foreach ($things as $name => $where) { unset($this->requires[$type][$name]); } } return array( 'declares' => $this->declares, 'requires' => $this->requires, 'chain' => $this->chain, 'messages' => $this->messages, ); } } diff --git a/src/staticanalysis/parsers/phutilmodule/__init__.php b/src/parser/phutilmodule/__init__.php similarity index 100% rename from src/staticanalysis/parsers/phutilmodule/__init__.php rename to src/parser/phutilmodule/__init__.php diff --git a/src/repository/api/base/ArcanistRepositoryAPI.php b/src/repository/api/base/ArcanistRepositoryAPI.php index d58a8bad..549c03de 100644 --- a/src/repository/api/base/ArcanistRepositoryAPI.php +++ b/src/repository/api/base/ArcanistRepositoryAPI.php @@ -1,138 +1,143 @@ diffLinesOfContext; } public function setDiffLinesOfContext($lines) { $this->diffLinesOfContext = $lines; return $this; } public static function newAPIFromWorkingCopyIdentity( ArcanistWorkingCopyIdentity $working_copy) { $root = $working_copy->getProjectRoot(); if (!$root) { throw new ArcanistUsageException( "There is no readable '.arcconfig' file in the working directory or ". "any parent directory. Create an '.arcconfig' file to configure arc."); } if (@file_exists($root.'/.svn')) { phutil_require_module('arcanist', 'repository/api/subversion'); return new ArcanistSubversionAPI($root); } $git_root = self::discoverGitBaseDirectory($root); if ($git_root) { if (!Filesystem::pathsAreEquivalent($root, $git_root)) { throw new ArcanistUsageException( "'.arcconfig' file is located at '{$root}', but working copy root ". "is '{$git_root}'. Move '.arcconfig' file to the working copy root."); } phutil_require_module('arcanist', 'repository/api/git'); return new ArcanistGitAPI($root); } throw new ArcanistUsageException( "The current working directory is not part of a working copy for a ". "supported version control system (svn or git)."); } protected function __construct($path) { $this->path = $path; } public function getPath($to_file = null) { if ($to_file !== null) { return $this->path.'/'.ltrim($to_file, '/'); } else { return $this->path.'/'; } } public function getUntrackedChanges() { return $this->getWorkingCopyFilesWithMask(self::FLAG_UNTRACKED); } public function getUnstagedChanges() { return $this->getWorkingCopyFilesWithMask(self::FLAG_UNSTAGED); } public function getUncommittedChanges() { return $this->getWorkingCopyFilesWithMask(self::FLAG_UNCOMMITTED); } public function getMergeConflicts() { return $this->getWorkingCopyFilesWithMask(self::FLAG_CONFLICT); } private function getWorkingCopyFilesWithMask($mask) { $match = array(); foreach ($this->getWorkingCopyStatus() as $file => $flags) { if ($flags & $mask) { $match[] = $file; } } return $match; } private static function discoverGitBaseDirectory($root) { try { list($stdout) = execx( '(cd %s; git rev-parse --show-cdup)', $root); return Filesystem::resolvePath(rtrim($stdout, "\n"), $root); } catch (CommandException $ex) { if (preg_match('/^fatal: Not a git repository/', $ex->getStdErr())) { return null; } throw $ex; } } abstract public function getBlame($path); abstract public function getWorkingCopyStatus(); abstract public function getRawDiffText($path); abstract public function getOriginalFileData($path); abstract public function getCurrentFileData($path); } diff --git a/src/repository/api/git/ArcanistGitAPI.php b/src/repository/api/git/ArcanistGitAPI.php index 9eff1f51..8d920671 100644 --- a/src/repository/api/git/ArcanistGitAPI.php +++ b/src/repository/api/git/ArcanistGitAPI.php @@ -1,357 +1,362 @@ relativeCommit = $relative_commit; return $this; } public function getRelativeCommit() { if ($this->relativeCommit === null) { list($err) = exec_manual( '(cd %s; git rev-parse --verify HEAD^)', $this->getPath()); if ($err) { $this->relativeCommit = self::GIT_MAGIC_ROOT_COMMIT; } else { $this->relativeCommit = 'HEAD^'; } } return $this->relativeCommit; } private function getDiffOptions() { $options = array( '-M', '-C', '--no-ext-diff', '--no-color', '--src-prefix=a/', '--dst-prefix=b/', '-U'.$this->getDiffLinesOfContext(), ); return implode(' ', $options); } public function getFullGitDiff() { $options = $this->getDiffOptions(); list($stdout) = execx( "(cd %s; git diff {$options} %s --)", $this->getPath(), $this->getRelativeCommit()); return $stdout; } public function getRawDiffText($path) { $relative_commit = $this->getRelativeCommit(); $options = $this->getDiffOptions(); list($stdout) = execx( "(cd %s; git diff {$options} %s -- %s)", $this->getPath(), $this->getRelativeCommit(), $path); return $stdout; } public function getBranchName() { // TODO: consider: // // $ git rev-parse --abbrev-ref `git symbolic-ref HEAD` // // But that may fail if you're not on a branch. list($stdout) = execx( '(cd %s; git branch)', $this->getPath()); $matches = null; if (preg_match('/^\* (.+)$/m', $stdout, $matches)) { return $matches[1]; } return null; } public function getSourceControlPath() { // TODO: Try to get something useful here. return null; } public function getGitCommitLog() { $relative = $this->getRelativeCommit(); if ($relative == self::GIT_MAGIC_ROOT_COMMIT) { list($stdout) = execx( '(cd %s; git log HEAD)', $this->getPath()); } else { list($stdout) = execx( '(cd %s; git log %s..HEAD)', $this->getPath(), $this->getRelativeCommit()); } return $stdout; } public function getGitHistoryLog() { list($stdout) = execx( '(cd %s; git log -n%d %s)', $this->getPath(), self::SEARCH_LENGTH_FOR_PARENT_REVISIONS, $this->getRelativeCommit()); return $stdout; } public function getSourceControlBaseRevision() { list($stdout) = execx( '(cd %s; git rev-parse %s)', $this->getPath(), $this->getRelativeCommit()); return rtrim($stdout, "\n"); } public function getGitHeadRevision() { list($stdout) = execx( '(cd %s; git rev-parse HEAD)', $this->getPath()); return rtrim($stdout, "\n"); } public function getWorkingCopyStatus() { if (!isset($this->status)) { // Find committed changes. list($stdout) = execx( '(cd %s; git diff --no-ext-diff --raw %s --)', $this->getPath(), $this->getRelativeCommit()); $files = $this->parseGitStatus($stdout); // Find uncommitted changes. list($stdout) = execx( '(cd %s; git diff --no-ext-diff --raw HEAD --)', $this->getPath()); $files += $this->parseGitStatus($stdout); // Find untracked files. list($stdout) = execx( '(cd %s; git ls-files --others --exclude-standard)', $this->getPath()); $stdout = rtrim($stdout, "\n"); if (strlen($stdout)) { $stdout = explode("\n", $stdout); foreach ($stdout as $file) { $files[$file] = self::FLAG_UNTRACKED; } } // Find unstaged changes. list($stdout) = execx( '(cd %s; git ls-files -m)', $this->getPath()); $stdout = rtrim($stdout, "\n"); if (strlen($stdout)) { $stdout = explode("\n", $stdout); foreach ($stdout as $file) { $files[$file] = self::FLAG_UNSTAGED; } } $this->status = $files; } return $this->status; } public function amendGitHeadCommit($message) { execx( '(cd %s; git commit --amend --message %s)', $this->getPath(), $message); } public function getPreReceiveHookStatus($old_ref, $new_ref) { list($stdout) = execx( '(cd %s && git diff --no-ext-diff --raw %s %s --)', $this->getPath(), $old_ref, $new_ref); return $this->parseGitStatus($stdout, $full = true); } private function parseGitStatus($status, $full = false) { static $flags = array( 'A' => self::FLAG_ADDED, 'M' => self::FLAG_MODIFIED, 'D' => self::FLAG_DELETED, ); $status = trim($status); $lines = array(); foreach (explode("\n", $status) as $line) { if ($line) { $lines[] = preg_split("/[ \t]/", $line); } } $files = array(); foreach ($lines as $line) { $mask = 0; $flag = $line[4]; $file = $line[5]; foreach ($flags as $key => $bits) { if ($flag == $key) { $mask |= $bits; } } if ($full) { $files[$file] = array( 'mask' => $mask, 'ref' => rtrim($line[3], '.'), ); } else { $files[$file] = $mask; } } return $files; } public function getBlame($path) { // TODO: 'git blame' supports --porcelain and we should probably use it. list($stdout) = execx( '(cd %s; git blame -w -C %s -- %s)', $this->getPath(), $this->getRelativeCommit(), $path); $blame = array(); foreach (explode("\n", trim($stdout)) as $line) { if (!strlen($line)) { continue; } // lines predating a git repo's history are blamed to the oldest revision, // with the commit hash prepended by a ^. we shouldn't count these lines // as blaming to the oldest diff's unfortunate author if ($line[0] == '^') { continue; } $matches = null; $ok = preg_match( '/^([0-9a-f]+)[^(]+?[(](.*?) +\d\d\d\d-\d\d-\d\d/', $line, $matches); if (!$ok) { throw new Exception("Bad blame? `{$line}'"); } $revision = $matches[1]; $author = $matches[2]; $blame[] = array($author, $revision); } return $blame; } public function getOriginalFileData($path) { return $this->getFileDataAtRevision($path, $this->getRelativeCommit()); } public function getCurrentFileData($path) { return $this->getFileDataAtRevision($path, 'HEAD'); } private function parseGitTree($stdout) { $result = array(); $stdout = trim($stdout); if (!strlen($stdout)) { return $result; } $lines = explode("\n", $stdout); foreach ($lines as $line) { $matches = array(); $ok = preg_match( '/^(\d{6}) (blob|tree) ([a-z0-9]{40})[\t](.*)$/', $line, $matches); if (!$ok) { throw new Exception("Failed to parse git ls-tree output!"); } $result[$matches[4]] = array( 'mode' => $matches[1], 'type' => $matches[2], 'ref' => $matches[3], ); } return $result; } private function getFileDataAtRevision($path, $revision) { // NOTE: We don't want to just "git show {$revision}:{$path}" since if the // path was a directory at the given revision we'll get a list of its files // and treat it as though it as a file containing a list of other files, // which is silly. list($stdout) = execx( '(cd %s && git ls-tree %s -- %s)', $this->getPath(), $revision, $path); $info = $this->parseGitTree($stdout); if (empty($info[$path])) { // No such path, or the path is a directory and we executed 'ls-tree dir/' // and got a list of its contents back. return null; } if ($info[$path]['type'] != 'blob') { // Path is or was a directory, not a file. return null; } list($stdout) = execx( '(cd %s && git cat-file blob %s)', $this->getPath(), $info[$path]['ref']); return $stdout; } } diff --git a/src/repository/api/subversion/ArcanistSubversionAPI.php b/src/repository/api/subversion/ArcanistSubversionAPI.php index b3f80b15..38193dac 100644 --- a/src/repository/api/subversion/ArcanistSubversionAPI.php +++ b/src/repository/api/subversion/ArcanistSubversionAPI.php @@ -1,437 +1,442 @@ getSVNStatus() as $path => $mask) { if ($mask & self::FLAG_CONFLICT) { return true; } } return false; } public function getWorkingCopyStatus() { return $this->getSVNStatus(); } public function getSVNBaseRevisions() { if ($this->svnBaseRevisions === null) { $this->getSVNStatus(); } return $this->svnBaseRevisions; } public function getSVNStatus($with_externals = false) { if ($this->svnStatus === null) { list($status) = execx('(cd %s && svn --xml status)', $this->getPath()); $xml = new SimpleXMLElement($status); if (count($xml->target) != 1) { throw new Exception("Expected exactly one XML status target."); } $externals = array(); $files = array(); $target = $xml->target[0]; $this->svnBaseRevisions = array(); foreach ($target->entry as $entry) { $path = (string)$entry['path']; $mask = 0; $props = (string)($entry->{'wc-status'}[0]['props']); $item = (string)($entry->{'wc-status'}[0]['item']); $base = (string)($entry->{'wc-status'}[0]['revision']); $this->svnBaseRevisions[$path] = $base; switch ($props) { case 'none': case 'normal': break; case 'modified': $mask |= self::FLAG_MODIFIED; break; default: throw new Exception("Unrecognized property status '{$props}'."); } switch ($item) { case 'normal': break; case 'external': $mask |= self::FLAG_EXTERNALS; $externals[] = $path; break; case 'unversioned': $mask |= self::FLAG_UNTRACKED; break; case 'obstructed': $mask |= self::FLAG_OBSTRUCTED; break; case 'missing': $mask |= self::FLAG_MISSING; break; case 'added': $mask |= self::FLAG_ADDED; break; case 'modified': $mask |= self::FLAG_MODIFIED; break; case 'deleted': $mask |= self::FLAG_DELETED; break; default: throw new Exception("Unrecognized item status '{$item}'."); } $files[$path] = $mask; } foreach ($files as $path => $mask) { foreach ($externals as $external) { if (!strncmp($path, $external, strlen($external))) { $files[$path] |= self::FLAG_EXTERNALS; } } } $this->svnStatus = $files; } $status = $this->svnStatus; if (!$with_externals) { foreach ($status as $path => $mask) { if ($mask & ArcanistRepositoryAPI::FLAG_EXTERNALS) { unset($status[$path]); } } } return $status; } public function getSVNProperty($path, $property) { list($stdout) = execx( 'svn propget %s %s@', $property, $this->getPath($path)); return trim($stdout); } public function getSourceControlPath() { return idx($this->getSVNInfo('/'), 'URL'); } public function getSourceControlBaseRevision() { $info = $this->getSVNInfo('/'); return $info['URL'].'@'.$info['Revision']; } public function getBranchName() { return 'svn'; } public function buildInfoFuture($path) { if ($path == '/') { // When the root of a working copy is referenced by a symlink and you // execute 'svn info' on that symlink, svn fails. This is a longstanding // bug in svn: // // See http://subversion.tigris.org/issues/show_bug.cgi?id=2305 // // To reproduce, do: // // $ ln -s working_copy working_link // $ svn info working_copy # ok // $ svn info working_link # fails // // Work around this by cd-ing into the directory before executing // 'svn info'. return new ExecFuture( '(cd %s && svn info .)', $this->getPath()); } else { // Note: here and elsewhere we need to append "@" to the path because if // a file has a literal "@" in it, everything after that will be // interpreted as a revision. By appending "@" with no argument, SVN // parses it properly. return new ExecFuture( 'svn info %s@', $this->getPath($path)); } } public function buildDiffFuture($path) { // The "--depth empty" flag prevents us from picking up changes in // children when we run 'diff' against a directory. return new ExecFuture( '(cd %s; svn diff --depth empty --diff-cmd diff -x -U%d %s)', $this->getPath(), $this->getDiffLinesOfContext(), $path); } public function primeSVNInfoResult($path, $result) { $this->svnInfoRaw[$path] = $result; return $this; } public function primeSVNDiffResult($path, $result) { $this->svnDiffRaw[$path] = $result; return $this; } public function getSVNInfo($path) { if (empty($this->svnInfo[$path])) { if (empty($this->svnInfoRaw[$path])) { $this->svnInfoRaw[$path] = $this->buildInfoFuture($path)->resolve(); } list($err, $stdout) = $this->svnInfoRaw[$path]; if ($err) { throw new Exception( "Error #{$err} executing svn info against '{$path}'."); } $patterns = array( '/^(URL): (\S+)$/m', '/^(Revision): (\d+)$/m', '/^(Last Changed Author): (\S+)$/m', '/^(Last Changed Rev): (\d+)$/m', '/^(Last Changed Date): (.+) \(.+\)$/m', '/^(Copied From URL): (\S+)$/m', '/^(Copied From Rev): (\d+)$/m', ); $result = array(); foreach ($patterns as $pattern) { $matches = null; if (preg_match($pattern, $stdout, $matches)) { $result[$matches[1]] = $matches[2]; } } if (isset($result['Last Changed Date'])) { $result['Last Changed Date'] = strtotime($result['Last Changed Date']); } if (empty($result)) { throw new Exception('Unable to parse SVN info.'); } $this->svnInfo[$path] = $result; } return $this->svnInfo[$path]; } public function getRawDiffText($path) { $status = $this->getSVNStatus(); if (!isset($status[$path])) { return null; } $status = $status[$path]; // Build meaningful diff text for "svn copy" operations. if ($status & ArcanistRepositoryAPI::FLAG_ADDED) { $info = $this->getSVNInfo($path); if (!empty($info['Copied From URL'])) { return $this->buildSyntheticAdditionDiff( $path, $info['Copied From URL'], $info['Copied From Rev']); } } // If we run "diff" on a binary file which doesn't have the "svn:mime-type" // of "application/octet-stream", `diff' will explode in a rain of // unhelpful hellfire as it tries to build a textual diff of the two // files. We just fix this inline since it's pretty unambiguous. // TODO: Move this to configuration? $matches = null; if (preg_match('/\.(gif|png|jpe?g|swf|pdf|ico)$/i', $path, $matches)) { $mime = $this->getSVNProperty($path, 'svn:mime-type'); if ($mime != 'application/octet-stream') { execx( 'svn propset svn:mime-type application/octet-stream %s', $this->getPath($path)); } } if (empty($this->svnDiffRaw[$path])) { $this->svnDiffRaw[$path] = $this->buildDiffFuture($path)->resolve(); } list($err, $stdout, $stderr) = $this->svnDiffRaw[$path]; // Note: GNU Diff returns 2 when SVN hands it binary files to diff and they // differ. This is not an error; it is documented behavior. But SVN isn't // happy about it. SVN will exit with code 1 and return the string below. if ($err != 0 && $stderr !== "svn: 'diff' returned 2\n") { throw new Exception( "svn diff returned unexpected error code: $err\n". "stdout: $stdout\n". "stderr: $stderr"); } if ($err == 0 && empty($stdout)) { // If there are no changes, 'diff' exits with no output, but that means // we can not distinguish between empty and unmodified files. Build a // synthetic "diff" without any changes in it. return $this->buildSyntheticUnchangedDiff($path); } return $stdout; } protected function buildSyntheticAdditionDiff($path, $source, $rev) { $type = $this->getSVNProperty($path, 'svn:mime-type'); if ($type == 'application/octet-stream') { return <<getPath($path))) { return null; } $data = Filesystem::readFile($this->getPath($path)); list($orig) = execx('svn cat %s@%s', $source, $rev); $src = new TempFile(); $dst = new TempFile(); Filesystem::writeFile($src, $orig); Filesystem::writeFile($dst, $data); list($err, $diff) = exec_manual( 'diff -L a/%s -L b/%s -U%d %s %s', str_replace($this->getSourceControlPath().'/', '', $source), $path, $this->getDiffLinesOfContext(), $src, $dst); if ($err == 1) { // 1 means there are differences. return <<buildSyntheticUnchangedDiff($path); } } protected function buildSyntheticUnchangedDiff($path) { $full_path = $this->getPath($path); if (is_dir($full_path)) { return null; } $data = Filesystem::readFile($full_path); $lines = explode("\n", $data); $len = count($lines); foreach ($lines as $key => $line) { $lines[$key] = ' '.$line; } $lines = implode("\n", $lines); return <<getPath(), $path); $stdout = trim($stdout); if (!strlen($stdout)) { // Empty file. return $blame; } foreach (explode("\n", $stdout) as $line) { $m = array(); if (!preg_match('/^\s*(\d+)\s+(\S+)/', $line, $m)) { throw new Exception("Bad blame? `{$line}'"); } $revision = $m[1]; $author = $m[2]; $blame[] = array($author, $revision); } return $blame; } public function getOriginalFileData($path) { // SVN issues warnings for nonexistent paths, directories, etc., but still // returns no error code. However, for new paths in the working copy it // fails. Assume that failure means the original file does not exist. list($err, $stdout) = exec_manual( '(cd %s && svn cat %s@)', $this->getPath(), $path); if ($err) { return null; } return $stdout; } public function getCurrentFileData($path) { $full_path = $this->getPath($path); if (Filesystem::pathExists($full_path)) { return Filesystem::readFile($full_path); } return null; } } diff --git a/src/unit/engine/base/ArcanistBaseUnitTestEngine.php b/src/unit/engine/base/ArcanistBaseUnitTestEngine.php index cfc1b6f7..a8f5b41d 100644 --- a/src/unit/engine/base/ArcanistBaseUnitTestEngine.php +++ b/src/unit/engine/base/ArcanistBaseUnitTestEngine.php @@ -1,60 +1,65 @@ 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); } abstract public function run(); } diff --git a/src/unit/engine/phutil/PhutilUnitTestEngine.php b/src/unit/engine/phutil/PhutilUnitTestEngine.php index 41c9ca8d..5c7118f0 100644 --- a/src/unit/engine/phutil/PhutilUnitTestEngine.php +++ b/src/unit/engine/phutil/PhutilUnitTestEngine.php @@ -1,112 +1,117 @@ 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); $path = Filesystem::resolvePath($path); if ($path == $library_root) { continue; } if (!is_dir($path)) { $path = dirname($path); } $library_path = Filesystem::readablePath($path, $library_root); if (basename($library_path) == '__tests__') { // Okay, this is a __tests__ module. } else { $exists = $bootloader->moduleExists( $library_name, $library_path.'/__tests__'); if ($exists) { // This is a module which has a __tests__ module in it. $path .= '/__tests__'; } else { // Look for a parent named __tests__. $rpos = strrpos($library_path, '/__tests__'); if ($rpos === false) { // No tests to run since there is no child or parent module named // __tests__. continue; } // Select the parent named __tests__. $path = substr($path, 0, $rpos + strlen('/__tests__')); } } $module_name = Filesystem::readablePath($path, $library_root); $module_key = $library_name.':'.$module_name; $tests[$module_key] = array( 'library' => $library_name, 'root' => $library_root, 'module' => $module_name, ); } if (!$tests) { throw new ArcanistNoEffectException("No tests to run."); } $run_tests = array(); foreach ($tests as $test) { $symbols = id(new PhutilSymbolLoader()) ->setType('class') ->setLibrary($test['library']) ->setModule($test['module']) ->setAncestorClass('ArcanistPhutilTestCase') ->selectAndLoadSymbols(); foreach ($symbols as $symbol) { $run_tests[$symbol['name']] = true; } } $run_tests = array_keys($run_tests); if (!$run_tests) { throw new ArcanistNoEffectException( "No tests to run. You may need to rebuild the phutil library map."); } $results = array(); foreach ($run_tests as $test_class) { PhutilSymbolLoader::loadClass($test_class); $test_case = newv($test_class, array()); $results[] = $test_case->run(); } if ($results) { $results = call_user_func_array('array_merge', $results); } return $results; } } diff --git a/src/unit/engine/phutil/__tests__/PhutilUnitTestEngineTestCase.php b/src/unit/engine/phutil/__tests__/PhutilUnitTestEngineTestCase.php index db3af08c..5c3689a9 100644 --- a/src/unit/engine/phutil/__tests__/PhutilUnitTestEngineTestCase.php +++ b/src/unit/engine/phutil/__tests__/PhutilUnitTestEngineTestCase.php @@ -1,29 +1,34 @@ assertEqual(1, 1, 'This test is expected to pass.'); } public function testFail() { $this->assertEqual(1, 2, 'This test is expected to fail.'); } } diff --git a/src/unit/engine/phutil/testcase/ArcanistPhutilTestCase.php b/src/unit/engine/phutil/testcase/ArcanistPhutilTestCase.php index 5ac268d3..6b2024f6 100644 --- a/src/unit/engine/phutil/testcase/ArcanistPhutilTestCase.php +++ b/src/unit/engine/phutil/testcase/ArcanistPhutilTestCase.php @@ -1,91 +1,96 @@ failTest($message); throw new ArcanistPhutilTestTerminatedException(); } final protected function assertFailure($message) { $this->failTest($message); throw new ArcanistPhutilTestTerminatedException(); } final private function failTest($reason) { $result = new ArcanistUnitTestResult(); $result->setName($this->runningTest); $result->setResult(ArcanistUnitTestResult::RESULT_FAIL); $result->setUserData($reason); $this->results[] = $result; } final private function passTest($reason) { $result = new ArcanistUnitTestResult(); $result->setName($this->runningTest); $result->setResult(ArcanistUnitTestResult::RESULT_PASS); $result->setUserData($reason); $this->results[] = $result; } final public function run() { $this->results = array(); $reflection = new ReflectionClass($this); foreach ($reflection->getMethods() as $method) { $name = $method->getName(); if (preg_match('/^test/', $name)) { $this->runningTest = $name; try { call_user_func_array( array($this, $name), array()); $this->passTest("All assertions passed."); } catch (ArcanistPhutilTestTerminatedException $ex) { // Continue with the next test. } catch (Exception $ex) { $this->failTest($ex->getMessage()); } } } return $this->results; } } diff --git a/src/unit/engine/phutil/testcase/exception/ArcanistPhutilTestTerminatedException.php b/src/unit/engine/phutil/testcase/exception/ArcanistPhutilTestTerminatedException.php index 2d267f43..1c80ea4c 100644 --- a/src/unit/engine/phutil/testcase/exception/ArcanistPhutilTestTerminatedException.php +++ b/src/unit/engine/phutil/testcase/exception/ArcanistPhutilTestTerminatedException.php @@ -1,19 +1,24 @@ name = $name; return $this; } public function getName() { return $this->name; } public function setResult($result) { $this->result = $result; return $this; } public function getResult() { return $this->result; } public function setUserData($user_data) { $this->userData = $user_data; return $this; } public function getUserData() { return $this->userData; } } diff --git a/src/workflow/amend/ArcanistAmendWorkflow.php b/src/workflow/amend/ArcanistAmendWorkflow.php index 4c14013f..a8a49d44 100644 --- a/src/workflow/amend/ArcanistAmendWorkflow.php +++ b/src/workflow/amend/ArcanistAmendWorkflow.php @@ -1,140 +1,145 @@ array( 'help' => "Show the amended commit message." ), 'revision' => array( 'param' => 'revision_id', 'help' => "Amend a specific revision. If you do not specify a revision, ". "arc will look in the commit message at HEAD.", ), ); } public function run() { $repository_api = $this->getRepositoryAPI(); if (!($repository_api instanceof ArcanistGitAPI)) { throw new ArcanistUsageException( "You may only run 'arc amend' in a git working copy."); } if ($repository_api->getUncommittedChanges()) { throw new ArcanistUsageException( "You have uncommitted changes in this branch. Stage and commit (or ". "revert) them before proceeding."); } if ($this->getArgument('revision')) { $revision_id = $this->getArgument('revision'); } else { $log = $repository_api->getGitCommitLog(); $parser = new ArcanistDiffParser(); $changes = $parser->parseDiff($log); if (count($changes) != 1) { throw new Exception("Expected one log."); } $change = reset($changes); if ($change->getType() != ArcanistDiffChangeType::TYPE_MESSAGE) { throw new Exception("Expected message change."); } $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $change->getMetadata('message')); $revision_id = $message->getRevisionID(); if (!$revision_id) { throw new ArcanistUsageException( "No revision specified with '--revision', and no Differential ". "revision marker in HEAD."); } } // TODO: The old 'arc amend' had a check here to see if you were running // 'arc amend' with an explicit revision but HEAD already had another // revision in it. Maybe this is worth restoring? $conduit = $this->getConduit(); $message = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision_id, )); if ($this->getArgument('show')) { echo $message."\n"; } else { $repository_api->amendGitHeadCommit($message); echo "Amended commit message.\n"; $working_copy = $this->getWorkingCopy(); $remote_hooks = $working_copy->getConfig('remote_hooks_installed', false); if (!$remote_hooks) { echo "According to .arcconfig, remote commit hooks are not installed ". "for this project, so the revision will be marked committed now. ". "Consult the documentation for instructions on installing hooks.". "\n\n"; $mark_workflow = $this->buildChildWorkflow( 'mark-committed', array($revision_id)); $mark_workflow->run(); } echo phutil_console_wrap( "You may now push this commit upstream, as appropriate (e.g. with ". "'git push', or 'git svn dcommit', or by printing and faxing it).\n"); } return 0; } protected function getSupportedRevisionControlSystems() { return array('git'); } } diff --git a/src/workflow/base/ArcanistBaseWorkflow.php b/src/workflow/base/ArcanistBaseWorkflow.php index 410040ff..8772422b 100644 --- a/src/workflow/base/ArcanistBaseWorkflow.php +++ b/src/workflow/base/ArcanistBaseWorkflow.php @@ -1,583 +1,588 @@ arcanistConfiguration = $arcanist_configuration; return $this; } public function getArcanistConfiguration() { return $this->arcanistConfiguration; } public function getCommandHelp() { return get_class($this).": Undocumented"; } public function requiresWorkingCopy() { return false; } public function requiresConduit() { return false; } public function requiresAuthentication() { return false; } public function requiresRepositoryAPI() { return false; } public function setCommand($command) { $this->command = $command; return $this; } public function getCommand() { return $this->command; } public function setUserName($user_name) { $this->userName = $user_name; return $this; } public function getUserName() { return $this->userName; } public function getArguments() { return array(); } private function setParentWorkflow($parent_workflow) { $this->parentWorkflow = $parent_workflow; return $this; } protected function getParentWorkflow() { return $this->parentWorkflow; } public function buildChildWorkflow($command, array $argv) { $arc_config = $this->getArcanistConfiguration(); $workflow = $arc_config->buildWorkflow($command); $workflow->setParentWorkflow($this); $workflow->setCommand($command); if ($this->repositoryAPI) { $workflow->setRepositoryAPI($this->repositoryAPI); } if ($this->userGUID) { $workflow->setUserGUID($this->getUserGUID()); $workflow->setUserName($this->getUserName()); } if ($this->conduit) { $workflow->setConduit($this->conduit); } if ($this->workingCopy) { $workflow->setWorkingCopy($this->workingCopy); } $workflow->setArcanistConfiguration($arc_config); $workflow->parseArguments(array_values($argv)); return $workflow; } public function getArgument($key, $default = null) { $args = $this->arguments; if (!array_key_exists($key, $args)) { return $default; } return $args[$key]; } final public function getCompleteArgumentSpecification() { $spec = $this->getArguments(); $arc_config = $this->getArcanistConfiguration(); $command = $this->getCommand(); $spec += $arc_config->getCustomArgumentsForCommand($command); return $spec; } public function parseArguments(array $args) { $spec = $this->getCompleteArgumentSpecification(); $dict = array(); $more_key = null; if (!empty($spec['*'])) { $more_key = $spec['*']; unset($spec['*']); $dict[$more_key] = array(); } $short_to_long_map = array(); foreach ($spec as $long => $options) { if (!empty($options['short'])) { $short_to_long_map[$options['short']] = $long; } } $more = array(); for ($ii = 0; $ii < count($args); $ii++) { $arg = $args[$ii]; $arg_name = null; $arg_key = null; if ($arg == '--') { $more = array_merge( $more, array_slice($args, $ii + 1)); break; } else if (!strncmp($arg, '--', 2)) { $arg_key = substr($arg, 2); if (!array_key_exists($arg_key, $spec)) { throw new ArcanistUsageException( "Unknown argument '{$arg_key}'. Try 'arc help'."); } } else if (!strncmp($arg, '-', 1)) { $arg_key = substr($arg, 1); if (empty($short_to_long_map[$arg_key])) { throw new ArcanistUsageException( "Unknown argument '{$arg_key}'. Try 'arc help'."); } $arg_key = $short_to_long_map[$arg_key]; } else { $more[] = $arg; continue; } $options = $spec[$arg_key]; if (empty($options['param'])) { $dict[$arg_key] = true; } else { if ($ii == count($args) - 1) { throw new ArcanistUsageException( "Option '{$arg}' requires a parameter."); } $dict[$arg_key] = $args[$ii + 1]; $ii++; } } if ($more) { if ($more_key) { $dict[$more_key] = $more; } else { $example = reset($more); throw new ArcanistUsageException( "Unrecognized argument '{$example}'. Try 'arc help'."); } } foreach ($dict as $key => $value) { if (empty($spec[$key]['conflicts'])) { continue; } foreach ($spec[$key]['conflicts'] as $conflict => $more) { if (isset($dict[$conflict])) { if ($more) { $more = ': '.$more; } else { $more = '.'; } // TODO: We'll always display these as long-form, when the user might // have typed them as short form. throw new ArcanistUsageException( "Arguments '--{$key}' and '--{$conflict}' are mutually exclusive". $more); } } } $this->arguments = $dict; $this->didParseArguments(); return $this; } protected function didParseArguments() { // Override this to customize workflow argument behavior. } public function getWorkingCopy() { if (!$this->workingCopy) { $workflow = get_class($this); throw new Exception( "This workflow ('{$workflow}') requires a working copy, override ". "requiresWorkingCopy() to return true."); } return $this->workingCopy; } public function setWorkingCopy( ArcanistWorkingCopyIdentity $working_copy) { $this->workingCopy = $working_copy; return $this; } public function getConduit() { if (!$this->conduit) { $workflow = get_class($this); throw new Exception( "This workflow ('{$workflow}') requires a Conduit, override ". "requiresConduit() to return true."); } return $this->conduit; } public function setConduit(ConduitClient $conduit) { $this->conduit = $conduit; return $this; } public function getUserGUID() { if (!$this->userGUID) { $workflow = get_class($this); throw new Exception( "This workflow ('{$workflow}') requires authentication, override ". "requiresAuthentication() to return true."); } return $this->userGUID; } public function setUserGUID($guid) { $this->userGUID = $guid; return $this; } public function setRepositoryAPI($api) { $this->repositoryAPI = $api; return $this; } public function getRepositoryAPI() { if (!$this->repositoryAPI) { $workflow = get_class($this); throw new Exception( "This workflow ('{$workflow}') requires a Repository API, override ". "requiresRepositoryAPI() to return true."); } return $this->repositoryAPI; } protected function shouldRequireCleanUntrackedFiles() { return empty($this->arguments['allow-untracked']); } protected function requireCleanWorkingCopy() { $api = $this->getRepositoryAPI(); $untracked = $api->getUntrackedChanges(); if ($this->shouldRequireCleanUntrackedFiles()) { if (!empty($untracked)) { throw new ArcanistUsageException( "You have untracked files in this working copy:\n". " ".implode("\n ", $untracked)."\n\n". "Add or delete them before proceeding, or include them in your ". "ignore rules. To bypass this check, use --allow-untracked."); } } if ($api->getMergeConflicts()) { throw new ArcanistUsageException( "You have merge conflicts in this working copy. Resolve merge ". "conflicts before proceeding."); } if ($api->getUnstagedChanges()) { throw new ArcanistUsageException( "You have unstaged changes in this branch. Stage and commit (or ". "revert) them before proceeding."); } if ($api->getUncommittedChanges()) { throw new ArcanistUsageException( "You have uncommitted changes in this branch. Commit (or revert) them ". "before proceeding."); } } protected function chooseRevision( array $revision_data, $revision_id, $prompt = null) { $revisions = array(); foreach ($revision_data as $data) { $ref = ArcanistDifferentialRevisionRef::newFromDictionary($data); $revisions[$ref->getID()] = $ref; } if ($revision_id) { $revision_id = $this->normalizeRevisionID($revision_id); if (empty($revisions[$revision_id])) { throw new ArcanistChooseInvalidRevisionException(); } return $revisions[$revision_id]; } if (!count($revisions)) { throw new ArcanistChooseNoRevisionsException(); } $repository_api = $this->getRepositoryAPI(); $candidates = array(); $cur_path = $repository_api->getPath(); foreach ($revisions as $revision) { $source_path = $revision->getSourcePath(); if ($source_path == $cur_path) { $candidates[] = $revision; } } if (count($candidates) == 1) { $candidate = reset($candidates); $revision_id = $candidate->getID(); } if ($revision_id) { return $revisions[$revision_id]; } $revision_indexes = array_keys($revisions); echo "\n"; $ii = 1; foreach ($revisions as $revision) { echo ' ['.$ii++.'] D'.$revision->getID().' '.$revision->getName()."\n"; } while (true) { $id = phutil_console_prompt($prompt); $id = trim(strtoupper($id), 'D'); if (isset($revisions[$id])) { return $revisions[$id]; } if (isset($revision_indexes[$id - 1])) { return $revisions[$revision_indexes[$id - 1]]; } } } protected function loadDiffBundleFromConduit( ConduitClient $conduit, $diff_id) { return $this->loadBundleFromConduit( $conduit, array( 'diff_id' => $diff_id, )); } protected function loadRevisionBundleFromConduit( ConduitClient $conduit, $revision_id) { return $this->loadBundleFromConduit( $conduit, array( 'revision_id' => $revision_id, )); } private function loadBundleFromConduit( ConduitClient $conduit, $params) { $future = $conduit->callMethod('differential.getdiff', $params); $diff = $future->resolve(); $changes = array(); foreach ($diff['changes'] as $changedict) { $changes[] = ArcanistDiffChange::newFromDictionary($changedict); } $bundle = ArcanistBundle::newFromChanges($changes); return $bundle; } protected function getChangedLines($path, $mode) { if (is_dir($path)) { return array(); } $change = $this->getChange($path); $lines = $change->getChangedLines($mode); return array_keys($lines); } private function getChange($path) { $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistSubversionAPI) { if (empty($this->changeCache[$path])) { $diff = $repository_api->getRawDiffText($path); $parser = new ArcanistDiffParser(); $changes = $parser->parseDiff($diff); if (count($changes) != 1) { throw new Exception("Expected exactly one change."); } $this->changeCache[$path] = reset($changes); } } else { if (empty($this->changeCache)) { $diff = $repository_api->getFullGitDiff(); $parser = new ArcanistDiffParser(); $changes = $parser->parseDiff($diff); foreach ($changes as $change) { $this->changeCache[$change->getCurrentPath()] = $change; } } } if (empty($this->changeCache[$path])) { if ($repository_api instanceof ArcanistGitAPI) { // This can legitimately occur under git if you make a change, "git // commit" it, and then revert the change in the working copy and run // "arc lint". $change = new ArcanistDiffChange(); $change->setCurrentPath($path); return $change; } else { throw new Exception( "Trying to get change for unchanged path '{$path}'!"); } } return $this->changeCache[$path]; } final public function willRunWorkflow() { $spec = $this->getCompleteArgumentSpecification(); foreach ($this->arguments as $arg => $value) { if (empty($spec[$arg])) { continue; } $options = $spec[$arg]; if (!empty($options['supports'])) { $system_name = $this->getRepositoryAPI()->getSourceControlSystemName(); if (!in_array($system_name, $options['supports'])) { $extended_info = null; if (!empty($options['nosupport'][$system_name])) { $extended_info = ' '.$options['nosupport'][$system_name]; } throw new ArcanistUsageException( "Option '--{$arg}' is not supported under {$system_name}.". $extended_info); } } } } protected function parseGitRelativeCommit(ArcanistGitAPI $api, array $argv) { if (count($argv) == 0) { return; } if (count($argv) != 1) { throw new ArcanistUsageException( "Specify exactly one commit."); } $base = reset($argv); if ($base == ArcanistGitAPI::GIT_MAGIC_ROOT_COMMIT) { $merge_base = $base; } else { list($err, $merge_base) = exec_manual( '(cd %s; git merge-base %s HEAD)', $api->getPath(), $base); if ($err) { throw new ArcanistUsageException( "Unable to parse git commit name '{$base}'."); } } $api->setRelativeCommit(trim($merge_base)); } protected function normalizeRevisionID($revision_id) { return ltrim(strtoupper($revision_id), 'D'); } protected function shouldShellComplete() { return true; } protected function getShellCompletions(array $argv) { return array(); } protected function getSupportedRevisionControlSystems() { return array('any'); } protected function getPassthruArgumentsAsMap($command) { $map = array(); foreach ($this->getCompleteArgumentSpecification() as $key => $spec) { if (!empty($spec['passthru'][$command])) { if (isset($this->arguments[$key])) { $map[$key] = $this->arguments[$key]; } } } return $map; } protected function getPassthruArgumentsAsArgv($command) { $spec = $this->getCompleteArgumentSpecification(); $map = $this->getPassthruArgumentsAsMap($command); $argv = array(); foreach ($map as $key => $value) { $argv[] = '--'.$key; if (!empty($spec[$key]['param'])) { $argv[] = $value; } } return $argv; } } diff --git a/src/workflow/commit/ArcanistCommitWorkflow.php b/src/workflow/commit/ArcanistCommitWorkflow.php index 7631df72..b5584bab 100644 --- a/src/workflow/commit/ArcanistCommitWorkflow.php +++ b/src/workflow/commit/ArcanistCommitWorkflow.php @@ -1,255 +1,260 @@ array( 'help' => "Show the command which would be issued, but do not actually ". "commit anything." ), 'revision' => array( 'param' => 'revision_id', 'help' => "Commit a specific revision. If you do not specify a revision, ". "arc will look for committable revisions.", ) ); } public function run() { $repository_api = $this->getRepositoryAPI(); $conduit = $this->getConduit(); $revision_data = $conduit->callMethodSynchronous( 'differential.find', array( 'query' => 'committable', 'guids' => array( $this->getUserGUID(), ), )); try { $revision_id = $this->getArgument('revision'); $revision = $this->chooseRevision( $revision_data, $revision_id, 'Which revision do you want to commit?'); } catch (ArcanistChooseInvalidRevisionException $ex) { throw new ArcanistUsageException( "Revision D{$revision_id} is not committable. You can only commit ". "revisions you own which have been 'accepted'."); } catch (ArcanistChooseNoRevisionsException $ex) { throw new ArcanistUsageException( "You have no committable Differential revisions. You can only commit ". "revisions you own which have been 'accepted'."); } $revision_id = $revision->getID(); $revision_name = $revision->getName(); $message = $conduit->callMethodSynchronous( 'differential.getcommitmessage', array( 'revision_id' => $revision_id, )); if ($this->getArgument('show')) { echo $message; return 0; } echo "Committing D{$revision_id} '{$revision_name}'...\n"; $files = $this->getCommitFileList($revision); $files = implode(' ', array_map('escapeshellarg', $files)); $message = escapeshellarg($message); $root = escapeshellarg($repository_api->getPath()); // Specify LANG explicitly so that UTF-8 commit messages don't break // subversion. $command = "(cd {$root} && LANG=en_US.utf8 svn commit {$files} -m {$message})"; $err = null; passthru($command, $err); if ($err) { throw new Exception("Executing 'svn commit' failed!"); } $working_copy = $this->getWorkingCopy(); $remote_hooks = $working_copy->getConfig('remote_hooks_installed', false); if (!$remote_hooks) { echo "According to .arcconfig, remote commit hooks are not installed ". "for this project, so the revision will be marked committed now. ". "Consult the documentation for instructions on installing hooks.". "\n\n"; $mark_workflow = $this->buildChildWorkflow( 'mark-committed', array($revision_id)); $mark_workflow->run(); } return $err; } protected function getCommitFileList( ArcanistDifferentialRevisionRef $revision) { $repository_api = $this->getRepositoryAPI(); if (!($repository_api instanceof ArcanistSubversionAPI)) { throw new ArcanistUsageException( "arc commit is only supported under SVN. Use arc amend under git."); } $conduit = $this->getConduit(); $revision_id = $revision->getID(); $revision_source = $revision->getSourcePath(); $working_copy = $repository_api->getPath(); if ($revision_source != $working_copy) { $prompt = "Revision was generated from '{$revision_source}', but the current ". "working copy root is '{$working_copy}'. Commit anyway?"; if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } } $commit_paths = $conduit->callMethodSynchronous( 'differential.getcommitpaths', array( 'revision_id' => $revision_id, )); $commit_paths = array_fill_keys($commit_paths, true); $status = $repository_api->getSVNStatus(); $modified_but_not_included = array(); foreach ($status as $path => $mask) { if (!empty($commit_paths[$path])) { continue; } foreach ($commit_paths as $will_commit => $ignored) { if (Filesystem::isDescendant($path, $will_commit)) { throw new ArcanistUsageException( "This commit includes the directory '{$will_commit}', but ". "it contains a modified path ('{$path}') which is NOT included ". "in the commit. Subversion can not handle this operation and ". "will commit the path anyway. You need to sort out the working ". "copy changes to '{$path}' before you may proceed with the ". "commit."); } } $modified_but_not_included[] = $path; } if ($modified_but_not_included) { if (count($modified_but_not_included) == 1) { $prefix = "A locally modified path is not included in this revision:"; $prompt = "It will NOT be committed. Commit this revision anyway?"; } else { $prefix = "Locally modified paths are not included in this revision:"; $prompt = "They will NOT be committed. Commit this revision anyway?"; } $this->promptFileWarning($prefix, $prompt, $modified_but_not_included); } $do_not_exist = array(); foreach ($commit_paths as $path => $ignored) { $disk_path = $repository_api->getPath($path); if (file_exists($disk_path)) { continue; } if (is_link($disk_path)) { continue; } if (idx($status, $path) & ArcanistRepositoryAPI::FLAG_DELETED) { continue; } $do_not_exist[] = $path; unset($commit_paths[$path]); } if ($do_not_exist) { if (count($do_not_exist) == 1) { $prefix = "Revision includes changes to a path that does not exist:"; $prompt = "Commit this revision anyway?"; } else { $prefix = "Revision includes changes to paths that do not exist:"; $prompt = "Commit this revision anyway?"; } $this->promptFileWarning($prefix, $prompt, $do_not_exist); } $files = array_keys($commit_paths); if (empty($files)) { throw new ArcanistUsageException( "There is nothing left to commit. None of the modified paths exist."); } return $files; } protected function promptFileWarning($prefix, $prompt, array $paths) { echo $prefix."\n\n"; foreach ($paths as $path) { echo " ".$path."\n"; } if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } } protected function getSupportedRevisionControlSystems() { return array('svn'); } } diff --git a/src/workflow/cover/ArcanistCoverWorkflow.php b/src/workflow/cover/ArcanistCoverWorkflow.php index 5a4beedc..2774ce3b 100644 --- a/src/workflow/cover/ArcanistCoverWorkflow.php +++ b/src/workflow/cover/ArcanistCoverWorkflow.php @@ -1,156 +1,161 @@ getRepositoryAPI(); $paths = $repository_api->getWorkingCopyStatus(); foreach ($paths as $path => $status) { if (is_dir($path)) { unset($paths[$path]); } if ($status & ArcanistRepositoryAPI::FLAG_UNTRACKED) { unset($paths[$path]); } if ($status & ArcanistRepositoryAPI::FLAG_ADDED) { unset($paths[$path]); } } $paths = array_keys($paths); if (!$paths) { throw new ArcanistNoEffectException( "You're covered, you didn't change anything."); } $changed = array(); foreach ($paths as $path) { $changed[$path] = $this->getChangedLines($path, 'cover'); } $covers = array(); foreach ($paths as $path) { $blame = $repository_api->getBlame($path); $lines = $changed[$path]; foreach ($lines as $line) { list($author, $revision) = idx($blame, $line, array(null, null)); if (!$author) { continue; } if (!isset($covers[$author])) { $covers[$author] = array(); } if (!isset($covers[$author][$path])) { $covers[$author][$path] = array( 'lines' => array(), 'revisions' => array(), ); } $covers[$author][$path]['lines'][] = $line; $covers[$author][$path]['revisions'][] = $revision; } } if (count($covers)) { foreach ($covers as $author => $files) { echo phutil_console_format( "**%s**\n", $author); foreach ($files as $file => $info) { $line_noun = count($info['lines']) == 1 ? 'line' : 'lines'; $lines = $this->readableSequenceFromLineNumbers($info['lines']); echo " {$file}: {$line_noun} {$lines}\n"; } } } else { echo "You're covered, your changes didn't touch anyone else's code.\n"; } return 0; } private function readableSequenceFromLineNumbers(array $array) { $sequence = array(); $last = null; $seq = null; $array = array_unique(array_map('intval', $array)); sort($array); foreach ($array as $element) { if ($seq !== null && $element == ($seq + 1)) { $seq++; continue; } if ($seq === null) { $last = $element; $seq = $element; continue; } if ($seq > $last) { $sequence[] = $last.'-'.$seq; } else { $sequence[] = $last; } $last = $element; $seq = $element; } if ($last !== null && $seq > $last) { $sequence[] = $last.'-'.$seq; } else if ($last !== null) { $sequence[] = $element; } return implode(', ', $sequence); } } diff --git a/src/workflow/diff/ArcanistDiffWorkflow.php b/src/workflow/diff/ArcanistDiffWorkflow.php index 1dfc9610..1b4bb42b 100644 --- a/src/workflow/diff/ArcanistDiffWorkflow.php +++ b/src/workflow/diff/ArcanistDiffWorkflow.php @@ -1,963 +1,968 @@ diffID; } public function getArguments() { return array( 'message' => array( 'short' => 'm', 'supports' => array( 'git', ), 'nosupport' => array( 'svn' => 'Edit revisions via the web interface when using SVN.', ), 'param' => 'message', 'help' => "When updating a revision under git, use the specified message ". "instead of prompting.", ), 'edit' => array( 'supports' => array( 'git', ), 'nosupport' => array( 'svn' => 'Edit revisions via the web interface when using SVN.', ), 'help' => "When updating a revision under git, edit revision information ". "before updating.", ), 'nounit' => array( 'help' => "Do not run unit tests.", ), 'nolint' => array( 'help' => "Do not run lint.", 'conflicts' => array( 'lintall' => '--nolint suppresses lint.', 'advice' => '--nolint suppresses lint.', 'apply-patches' => '--nolint suppresses lint.', 'never-apply-patches' => '--nolint suppresses lint.', ), ), 'only' => array( 'help' => "Only generate a diff, without running lint, unit tests, or other ". "auxiliary steps.", 'conflicts' => array( 'preview' => null, 'message' => '--only does not affect revisions.', 'edit' => '--only does not affect revisions.', 'lintall' => '--only suppresses lint.', 'advice' => '--only suppresses lint.', 'apply-patches' => '--only suppresses lint.', 'never-apply-patches' => '--only suppresses lint.', 'nounit' => '--only implies --nounit.', 'nolint' => '--only implies --nolint.', ), ), 'preview' => array( 'supports' => array( 'git', ), 'nosupport' => array( 'svn' => 'Revisions are never created directly when using SVN.', ), 'help' => "Instead of creating or updating a revision, only create a diff, ". "which you may later attach to a revision. This still runs lint ". "unit tests. See also --only.", 'conflicts' => array( 'only' => null, 'edit' => '--preview does affect revisions.', 'message' => '--preview does not update any revision.', ), ), 'allow-untracked' => array( 'help' => "Skip checks for untracked files in the working copy.", ), 'less-context' => array( 'help' => "Normally, files are diffed with full context: the entire file is ". "sent to Differential so reviewers can 'show more' and see it. If ". "you are making changes to very large files with tens of thousands ". "of lines, this may not work well. With this flag, a diff will ". "be created that has only a few lines of context.", ), 'lintall' => array( 'help' => "Raise all lint warnings, not just those on lines you changed.", 'passthru' => array( 'lint' => true, ), ), 'advice' => array( 'help' => "Raise lint advice in addition to lint warnings and errors.", 'passthru' => array( 'lint' => true, ), ), 'apply-patches' => array( 'help' => 'Apply patches suggested by lint to the working copy without '. 'prompting.', 'conflicts' => array( 'never-apply-patches' => true, ), 'passthru' => array( 'lint' => true, ), ), 'never-apply-patches' => array( 'help' => 'Never apply patches suggested by lint.', 'conflicts' => array( 'apply-patches' => true, ), 'passthru' => array( 'lint' => true, ), ), '*' => 'paths', ); } public function run() { $repository_api = $this->getRepositoryAPI(); if ($this->getArgument('less-context')) { $repository_api->setDiffLinesOfContext(3); } $conduit = $this->getConduit(); $this->requireCleanWorkingCopy(); $parent = null; $base_revision = $repository_api->getSourceControlBaseRevision(); $base_path = $repository_api->getSourceControlPath(); if ($repository_api instanceof ArcanistGitAPI) { $info = $this->getGitParentLogInfo(); if ($info['parent']) { $parent = $info['parent']; } if ($info['base_revision']) { $base_revision = $info['base_revision']; } if ($info['base_path']) { $base_path = $info['base_path']; } } $paths = $this->generateAffectedPaths(); $lint_result = $this->runLint($paths); $unit_result = $this->runUnit($paths); $changes = $this->generateChanges(); if (!$changes) { throw new ArcanistUsageException( "There are no changes to generate a diff from!"); } $change_list = array(); foreach ($changes as $change) { $change_list[] = $change->toDictionary(); } if ($lint_result === ArcanistLintWorkflow::RESULT_OKAY) { $lint = 'okay'; } else if ($lint_result === ArcanistLintWorkflow::RESULT_ERRORS) { $lint = 'fail'; } else if ($lint_result === ArcanistLintWorkflow::RESULT_WARNINGS) { $lint = 'warn'; } else if ($lint_result === ArcanistLintWorkflow::RESULT_SKIP) { $lint = 'skip'; } else { $lint = 'none'; } if ($unit_result === ArcanistUnitWorkflow::RESULT_OKAY) { $unit = 'okay'; } else if ($unit_result === ArcanistUnitWorkflow::RESULT_FAIL) { $unit = 'fail'; } else if ($unit_result === ArcanistUnitWorkflow::RESULT_UNSOUND) { $unit = 'warn'; } else if ($unit_result === ArcanistUnitWorkflow::RESULT_SKIP) { $unit = 'skip'; } else { $unit = 'none'; } $diff = array( 'changes' => $change_list, 'sourceMachine' => php_uname('n'), 'sourcePath' => $repository_api->getPath(), 'branch' => $repository_api->getBranchName(), 'sourceControlSystem' => $repository_api->getSourceControlSystemName(), 'sourceControlPath' => $base_path, 'sourceControlBaseRevision' => $base_revision, 'parentRevisionID' => $parent, 'lintStatus' => $lint, 'unitStatus' => $unit, ); $diff_info = $conduit->callMethodSynchronous( 'differential.creatediff', $diff); if ($this->unresolvedLint) { $data = array(); foreach ($this->unresolvedLint as $message) { $data[] = array( 'path' => $message->getPath(), 'line' => $message->getLine(), 'char' => $message->getChar(), 'code' => $message->getCode(), 'severity' => $message->getSeverity(), 'name' => $message->getName(), 'description' => $message->getDescription(), ); } $conduit->callMethodSynchronous( 'differential.setdiffproperty', array( 'diff_id' => $diff_info['diffid'], 'name' => 'arc:lint', 'data' => json_encode($data), )); } if ($this->unresolvedTests) { $data = array(); foreach ($this->unresolvedTests as $test) { $data[] = array( 'name' => $test->getName(), 'result' => $test->getResult(), 'userdata' => $test->getUserData(), ); } $conduit->callMethodSynchronous( 'differential.setdiffproperty', array( 'diff_id' => $diff_info['diffid'], 'name' => 'arc:unit', 'data' => json_encode($data), )); } if ($this->shouldOnlyCreateDiff()) { echo phutil_console_format( "Created a new Differential diff:\n". " **Diff URI:** __%s__\n\n", $diff_info['uri']); } else { $message = $this->getGitCommitMessage(); $revision = array( 'diffid' => $diff_info['diffid'], 'fields' => $message->getFields(), ); if ($message->getRevisionID()) { $update_message = $this->getUpdateMessage(); $revision['id'] = $message->getRevisionID(); $revision['message'] = $update_message; $future = $conduit->callMethod( 'differential.updaterevision', $revision); $result = $future->resolve(); echo "Updated an existing Differential revision:\n"; } else { $revision['user'] = $this->getUserGUID(); $future = $conduit->callMethod( 'differential.createrevision', $revision); $result = $future->resolve(); echo "Updating commit message to include Differential revision ID...\n"; $repository_api->amendGitHeadCommit( $message->getRawCorpus(). "\n\n". "Differential Revision: ".$result['revisionid']."\n"); echo "Created a new Differential revision:\n"; } $uri = $result['uri']; echo phutil_console_format( " **Revision URI:** __%s__\n\n", $uri); } echo "Included changes:\n"; foreach ($changes as $change) { echo ' '.$change->renderTextSummary()."\n"; } $this->diffID = $diff_info['diffid']; return 0; } protected function shouldOnlyCreateDiff() { $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistSubversionAPI) { return true; } return $this->getArgument('preview') || $this->getArgument('only'); } protected function findRevisionInformation() { return array(null, null); } private function generateAffectedPaths() { $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistSubversionAPI) { $file_list = new FileList($this->getArgument('paths', array())); $paths = $repository_api->getSVNStatus($externals = true); foreach ($paths as $path => $mask) { if (!$file_list->contains($repository_api->getPath($path), true)) { unset($paths[$path]); } } $warn_externals = array(); foreach ($paths as $path => $mask) { $any_mod = ($mask & ArcanistRepositoryAPI::FLAG_ADDED) || ($mask & ArcanistRepositoryAPI::FLAG_MODIFIED) || ($mask & ArcanistRepositoryAPI::FLAG_DELETED); if ($mask & ArcanistRepositoryAPI::FLAG_EXTERNALS) { unset($paths[$path]); if ($any_mod) { $warn_externals[] = $path; } } } if ($warn_externals && !$this->hasWarnedExternals) { echo phutil_console_format( "The working copy includes changes to 'svn:externals' paths. These ". "changes will not be included in the diff because SVN can not ". "commit 'svn:externals' changes alongside normal changes.". "\n\n". "Modified 'svn:externals' files:". "\n\n". ' '.phutil_console_wrap(implode("\n", $warn_externals), 8)); $prompt = "Generate a diff (with just local changes) anyway?"; if (!phutil_console_confirm($prompt)) { throw new ArcanistUserAbortException(); } else { $this->hasWarnedExternals = true; } } } else { $this->parseGitRelativeCommit( $repository_api, $this->getArgument('paths', array())); $paths = $repository_api->getWorkingCopyStatus(); } foreach ($paths as $path => $mask) { if ($mask & ArcanistRepositoryAPI::FLAG_UNTRACKED) { unset($paths[$path]); } } return $paths; } protected function generateChanges() { $repository_api = $this->getRepositoryAPI(); $parser = new ArcanistDiffParser(); if ($repository_api instanceof ArcanistSubversionAPI) { $paths = $this->generateAffectedPaths(); $this->primeSubversionWorkingCopyData($paths); // Check to make sure the user is diffing from a consistent base revision. // This is mostly just an abuse sanity check because it's silly to do this // and makes the code more difficult to effectively review, but it also // affects patches and makes them nonportable. $bases = $repository_api->getSVNBaseRevisions(); // Remove all files with baserev "0"; these files are new. foreach ($bases as $path => $baserev) { if ($bases[$path] == 0) { unset($bases[$path]); } } if ($bases) { // We have at least one path which isn't new. $repository_info = $repository_api->getSVNInfo('/'); $bases['.'] = $repository_info['Revision']; if ($bases['.']) { $rev = $bases['.']; foreach ($bases as $path => $baserev) { if ($baserev !== $rev) { $revlist = array(); foreach ($bases as $path => $baserev) { $revlist[] = " Revision {$baserev}, {$path}"; } $revlist = implode("\n", $revlist); throw new ArcanistUsageException( "Base revisions of changed paths are mismatched. Update all ". "paths to the same base revision before creating a diff: ". "\n\n". $revlist); } } } } $changes = $parser->parseSubversionDiff( $repository_api, $paths); } else if ($repository_api instanceof ArcanistGitAPI) { $diff = $repository_api->getFullGitDiff(); if (!strlen($diff)) { list($base, $tip) = $repository_api->getCommitRange(); if ($tip == 'HEAD') { if (preg_match('/\^+HEAD/', $base)) { $more = 'Did you mean HEAD^ instead of ^HEAD?'; } else { $more = 'Did you specify the wrong relative commit?'; } } else { $more = 'Did you specify the wrong commit range?'; } throw new ArcanistUsageException("No changes found. ({$more})"); } $changes = $parser->parseDiff($diff); } else { throw new Exception("Repository API is not supported."); } if (count($changes) > 250) { $count = number_format(count($changes)); $message = "This diff has a very large number of changes ({$count}). ". "Differential works best for changes which will receive detailed ". "human review, and not as well for large automated changes or ". "bulk checkins. Continue anyway?"; if (!phutil_console_confirm($message)) { throw new ArcanistUsageException( "Aborted generation of gigantic diff."); } } $limit = 1024 * 1024 * 4; foreach ($changes as $change) { $size = 0; foreach ($change->getHunks() as $hunk) { $size += strlen($hunk->getCorpus()); } if ($size > $limit) { $file_name = $change->getCurrentPath(); $change_size = number_format($size); $byte_warning = "Diff for '{$file_name}' with context is {$change_size} bytes in ". "length. Generally, source changes should not be this large. If ". "this file is a huge text file, try using the '--less-context' flag."; if ($repository_api instanceof ArcanistSubversionAPI) { throw new ArcanistUsageException( "{$byte_warning} If the file is not a text file, mark it as ". "binary with:". "\n\n". " $ svn propset svn:mime-type application/octet-stream ". "\n"); } else { $confirm = "{$byte_warning} If the file is not a text file, you can ". "mark it 'binary'. Mark this file as 'binary' and continue?"; if (phutil_console_confirm($confirm)) { $change->convertToBinaryChange(); } else { throw new ArcanistUsageException( "Aborted generation of gigantic diff."); } } } } // TODO: Ideally, we should do this later, after validating commit message // fields (i.e., test plan), in case there are large/slow file upload steps // involved. foreach ($changes as $change) { if ($change->getFileType() != ArcanistDiffChangeType::FILE_BINARY) { continue; } $path = $change->getCurrentPath(); $old_file = $repository_api->getOriginalFileData($path); $new_file = $repository_api->getCurrentFileData($path); $old_dict = $this->uploadFile($old_file, basename($path), 'old binary'); $new_dict = $this->uploadFile($new_file, basename($path), 'new binary'); if ($old_dict['guid']) { $change->setMetadata('old:binary-guid', $old_dict['guid']); } if ($new_dict['guid']) { $change->setMetadata('new:binary-guid', $new_dict['guid']); } $change->setMetadata('old:file:size', strlen($old_file)); $change->setMetadata('new:file:size', strlen($new_file)); $change->setMetadata('old:file:mime-type', $old_dict['mime']); $change->setMetadata('new:file:mime-type', $new_dict['mime']); if (preg_match('@^image/@', $new_dict['mime'])) { $change->setFileType(ArcanistDiffChangeType::FILE_IMAGE); } } return $changes; } private function uploadFile($data, $name, $desc) { $result = array( 'guid' => null, 'mime' => null, ); if (!strlen($data)) { return $result; } $future = new ExecFuture('file -ib -'); $future->write($data); list($mime_type) = $future->resolvex(); $mime_type = trim($mime_type); if (strpos($mime_type, ',') !== false) { // TODO: This is kind of silly, but 'file -ib' goes crazy on executables. $mime_type = reset(explode(',', $mime_type)); } $result['mime'] = $mime_type; // TODO: Make this configurable. $bin_limit = 1024 * 1024; // 1 MB limit if (strlen($data) > $bin_limit) { return $result; } $bytes = strlen($data); echo "Uploading {$desc} '{$name}' ({$mime_type}, {$bytes} bytes)...\n"; $guid = $this->getConduit()->callMethodSynchronous( 'file.upload', array( 'data_base64' => base64_encode($data), 'name' => $name, )); $result['guid'] = $guid; return $result; } /** * Retrieve the git message in HEAD if it isn't a primary template message. */ private function getGitUpdateMessage() { $repository_api = $this->getRepositoryAPI(); $parser = new ArcanistDiffParser($repository_api); $commit_messages = $repository_api->getGitCommitLog(); $commit_messages = $parser->parseDiff($commit_messages); $head = reset($commit_messages); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $head->getMetadata('message')); if ($message->getRevisionID()) { return null; } return trim($message->getRawCorpus()); } private function getGitCommitMessage() { $conduit = $this->getConduit(); $repository_api = $this->getRepositoryAPI(); $parser = new ArcanistDiffParser($repository_api); $commit_messages = $repository_api->getGitCommitLog(); $commit_messages = $parser->parseDiff($commit_messages); $problems = array(); $parsed = array(); foreach ($commit_messages as $key => $change) { $problems[$key] = array(); try { $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $change->getMetadata('message')); $message->pullDataFromConduit($conduit); $parsed[$key] = $message; } catch (ArcanistDifferentialCommitMessageParserException $ex) { $problems[$key][] = $ex; continue; } // TODO: Move this all behind Conduit. if (!$message->getRevisionID()) { if ($message->getFieldValue('reviewedByGUIDs')) { $problems[$key][] = new ArcanistUsageException( "When creating or updating a revision, use the 'Reviewers:' ". "field to specify reviewers, not 'Reviewed By:'. After the ". "revision is accepted, run 'arc amend' to update the commit ". "message."); } if (!$message->getFieldValue('title')) { $problems[$key][] = new ArcanistUsageException( "Commit message has no title. You must provide a title for this ". "revision."); } if (!$message->getFieldValue('testPlan')) { $problems[$key][] = new ArcanistUsageException( "Commit message has no 'Test Plan:'. You must provide a test ". "plan."); } } } $blessed = null; $revision_id = -1; foreach ($problems as $key => $problem_list) { if ($problem_list) { continue; } if ($revision_id === -1) { $revision_id = $parsed[$key]->getRevisionID(); $blessed = $parsed[$key]; } else { throw new ArcanistUsageException( "Changes in the specified commit range include more than one ". "commit with a valid template commit message. This is ambiguous, ". "your commit range should contain only one template commit ". "message. Alternatively, use --preview to ignore commit ". "messages."); } } if ($revision_id === -1) { $all_problems = call_user_func_array('array_merge', $problems); $desc = implode("\n", mpull($all_problems, 'getMessage')); if (count($problems) > 1) { throw new ArcanistUsageException( "All changes between the specified commits have template parsing ". "problems:\n\n".$desc."\n\nIf you only want to create a diff ". "(not a revision), use --preview to ignore commit messages."); } else if (count($problems) == 1) { throw new ArcanistUsageException( "Commit message is not properly formatted:\n\n".$desc."\n\n". "You should use the standard git commit template to provide a ". "commit message. If you only want to create a diff (not a ". "revision), use --preview to ignore commit messages."); } } if ($blessed) { if (!$blessed->getFieldValue('reviewerGUIDs') && !$blessed->getFieldValue('reviewerPHIDs')) { $message = "You have not specified any reviewers. Continue anyway?"; if (!phutil_console_confirm($message)) { throw new ArcanistUsageException('Specify reviewers and retry.'); } } } return $blessed; } private function getGitParentLogInfo() { $info = array( 'parent' => null, 'base_revision' => null, 'base_path' => null, ); $conduit = $this->getConduit(); $repository_api = $this->getRepositoryAPI(); $parser = new ArcanistDiffParser($repository_api); $history_messages = $repository_api->getGitHistoryLog(); if (!$history_messages) { // This can occur on the initial commit. return $info; } $history_messages = $parser->parseDiff($history_messages); foreach ($history_messages as $key => $change) { try { $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $change->getMetadata('message')); if ($message->getRevisionID() && $info['parent'] === null) { $info['parent'] = $message->getRevisionID(); } if ($message->getGitSVNBaseRevision() && $info['base_revision'] === null) { $info['base_revision'] = $message->getGitSVNBaseRevision(); $info['base_path'] = $message->getGitSVNBasePath(); } if ($info['parent'] && $info['base_revision']) { break; } } catch (ArcanistDifferentialCommitMessageParserException $ex) { // Ignore. } } return $info; } protected function primeSubversionWorkingCopyData($paths) { $repository_api = $this->getRepositoryAPI(); $futures = array(); $targets = array(); foreach ($paths as $path => $mask) { $futures[] = $repository_api->buildDiffFuture($path); $targets[] = array('command' => 'diff', 'path' => $path); $futures[] = $repository_api->buildInfoFuture($path); $targets[] = array('command' => 'info', 'path' => $path); } foreach ($futures as $key => $future) { $target = $targets[$key]; if ($target['command'] == 'diff') { $repository_api->primeSVNDiffResult( $target['path'], $future->resolve()); } else { $repository_api->primeSVNInfoResult( $target['path'], $future->resolve()); } } } private function getUpdateMessage() { $comments = $this->getArgument('message'); if (!strlen($comments)) { // When updating a revision using git without specifying '--message', try // to prefill with the message in HEAD if it isn't a template message. The // idea is that if you do: // // $ git commit -a -m 'fix some junk' // $ arc diff // // ...you shouldn't have to retype the update message. $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistGitAPI) { $comments = $this->getGitUpdateMessage(); } $template = $comments. "\n\n". "# Enter a brief description of the changes included in this update.". "\n"; $comments = id(new PhutilInteractiveEditor($template)) ->setName('differential-update-comments') ->editInteractively(); $comments = preg_replace('/^\s*#.*$/m', '', $comments); $comments = rtrim($comments); if (!strlen($comments)) { throw new ArcanistUserAbortException(); } } return $comments; } private function runLint($paths) { if ($this->getArgument('nolint') || $this->getArgument('only')) { return ArcanistLintWorkflow::RESULT_SKIP; } $repository_api = $this->getRepositoryAPI(); echo "Linting...\n"; try { $argv = $this->getPassthruArgumentsAsArgv('lint'); if ($repository_api instanceof ArcanistSubversionAPI) { $argv = array_merge($argv, array_keys($paths)); } else { $argv[] = $repository_api->getRelativeCommit(); } $lint_workflow = $this->buildChildWorkflow('lint', $argv); $lint_workflow->setShouldAmendChanges(true); $lint_result = $lint_workflow->run(); switch ($lint_result) { case ArcanistLintWorkflow::RESULT_OKAY: echo phutil_console_format( "** LINT OKAY ** No lint problems.\n"); break; case ArcanistLintWorkflow::RESULT_WARNINGS: $continue = phutil_console_confirm( "Lint issued unresolved warnings. Ignore them?"); if (!$continue) { throw new ArcanistUserAbortException(); } break; case ArcanistLintWorkflow::RESULT_ERRORS: echo phutil_console_format( "** LINT ERRORS ** Lint raised errors!\n"); $continue = phutil_console_confirm( "Lint issued unresolved errors! Ignore lint errors?"); if (!$continue) { throw new ArcanistUserAbortException(); } break; } $this->unresolvedLint = $lint_workflow->getUnresolvedMessages(); return $lint_result; } catch (ArcanistNoEngineException $ex) { echo "No lint engine configured for this project.\n"; } catch (ArcanistNoEffectException $ex) { echo "No paths to lint.\n"; } return null; } private function runUnit($paths) { if ($this->getArgument('nounit') || $this->getArgument('only')) { return ArcanistUnitWorkflow::RESULT_SKIP; } $repository_api = $this->getRepositoryAPI(); echo "Running unit tests...\n"; try { $argv = $this->getPassthruArgumentsAsArgv('unit'); if ($repository_api instanceof ArcanistSubversionAPI) { $argv = array_merge($argv, array_keys($paths)); } $unit_workflow = $this->buildChildWorkflow('unit', $argv); $unit_result = $unit_workflow->run(); switch ($unit_result) { case ArcanistUnitWorkflow::RESULT_OKAY: echo phutil_console_format( "** UNIT OKAY ** No unit test failures.\n"); break; case ArcanistUnitWorkflow::RESULT_UNSOUND: $continue = phutil_console_confirm( "Unit test results included failures, but all failing tests ". "are known to be unsound. Ignore unsound test failures?"); if (!$continue) { throw new ArcanistUserAbortException(); } break; case ArcanistUnitWorkflow::RESULT_FAIL: echo phutil_console_format( "** UNIT ERRORS ** Unit testing raised errors!\n"); $continue = phutil_console_confirm( "Unit test results include failures! Ignore test failures?"); if (!$continue) { throw new ArcanistUserAbortException(); } break; } $this->unresolvedTests = $unit_workflow->getUnresolvedTests(); return $unit_result; } catch (ArcanistNoEngineException $ex) { echo "No unit test engine is configured for this project.\n"; } catch (ArcanistNoEffectException $ex) { echo "No tests to run.\n"; } return null; } } diff --git a/src/workflow/export/ArcanistExportWorkflow.php b/src/workflow/export/ArcanistExportWorkflow.php index b409a860..eef575af 100644 --- a/src/workflow/export/ArcanistExportWorkflow.php +++ b/src/workflow/export/ArcanistExportWorkflow.php @@ -1,217 +1,222 @@ array( 'help' => "Export change as a git patch. This format is more complete than ". "unified, but less complete than arc bundles. These patches can be ". "applied with 'git apply' or 'arc patch'.", ), 'unified' => array( 'help' => "Export change as a unified patch. This format is less complete ". "than git patches or arc bundles. These patches can be applied with ". "'patch' or 'arc patch'.", ), 'arcbundle' => array( 'param' => 'file', 'help' => "Export change as an arc bundle. This format can represent all ". "changes. These bundles can be applied with 'arc patch'.", ), 'revision' => array( 'param' => 'revision_id', 'help' => "Instead of exporting changes from the working copy, export them ". "from a Differential revision." ), 'diff' => array( 'param' => 'diff_id', 'help' => "Instead of exporting changes from the working copy, export them ". "from a Differential diff." ), '*' => 'paths', ); } protected function didParseArguments() { $source = self::SOURCE_LOCAL; $requested = 0; if ($this->getArgument('revision')) { $source = self::SOURCE_REVISION; $requested++; } if ($this->getArgument('diff')) { $source = self::SOURCE_DIFF; $requested++; } if ($requested > 1) { throw new ArcanistUsageException( "Options '--revision' and '--diff' are not compatible. Choose exactly ". "one change source."); } $this->source = $source; $this->sourceID = $this->getArgument($source); $format = null; $requested = 0; if ($this->getArgument('git')) { $format = self::FORMAT_GIT; $requested++; } if ($this->getArgument('unified')) { $format = self::FORMAT_UNIFIED; $requested++; } if ($this->getArgument('arcbundle')) { $format = self::FORMAT_BUNDLE; $requested++; } if ($requested === 0) { throw new ArcanistUsageException( "Specify one of '--git', '--unified' or '--arcbundle ' to ". "choose an export format."); } else if ($requested > 1) { throw new ArcanistUsageException( "Options '--git', '--unified' and '--arcbundle' are not compatible. ". "Choose exactly one export format."); } $this->format = $format; } public function requiresConduit() { return $this->getSource() != self::SOURCE_LOCAL; } public function requiresAuthentication() { return $this->requiresConduit(); } public function requiresRepositoryAPI() { return $this->getSource() == self::SOURCE_LOCAL; } public function requiresWorkingCopy() { return $this->getSource() == self::SOURCE_LOCAL; } private function getSource() { return $this->source; } private function getSourceID() { return $this->sourceID; } private function getFormat() { return $this->format; } public function run() { $source = $this->getSource(); switch ($source) { case self::SOURCE_LOCAL: $repository_api = $this->getRepositoryAPI(); $parser = new ArcanistDiffParser(); if ($repository_api instanceof ArcanistGitAPI) { $this->parseGitRelativeCommit( $repository_api, $this->getArgument('paths')); $diff = $repository_api->getFullGitDiff(); $changes = $parser->parseDiff($diff); } else { // TODO: paths support $paths = $repository_api->getWorkingCopyStatus(); $changes = $parser->parseSubversionDiff( $repository_api, $paths); } $bundle = ArcanistBundle::newFromChanges($changes); break; case self::SOURCE_REVISION: $bundle = $this->loadRevisionBundleFromConduit( $this->getConduit(), $this->getSourceID()); break; case self::SOURCE_DIFF: $bundle = $this->loadDiffBundleFromConduit( $this->getConduit(), $this->getSourceID()); break; } $format = $this->getFormat(); switch ($format) { case self::FORMAT_GIT: echo $bundle->toGitPatch(); break; case self::FORMAT_UNIFIED: echo $bundle->toUnifiedDiff(); break; case self::FORMAT_BUNDLE: $path = $this->getArgument('arcbundle'); echo "Writing bundle to '{$path}'... "; $bundle->writeToDisk($path); echo "done.\n"; break; } return 0; } } diff --git a/src/workflow/git-hook-pre-receive/ArcanistGitHookPreReceiveWorkflow.php b/src/workflow/git-hook-pre-receive/ArcanistGitHookPreReceiveWorkflow.php index 72c2601a..af648e76 100644 --- a/src/workflow/git-hook-pre-receive/ArcanistGitHookPreReceiveWorkflow.php +++ b/src/workflow/git-hook-pre-receive/ArcanistGitHookPreReceiveWorkflow.php @@ -1,136 +1,141 @@ getWorkingCopy(); if (!$working_copy->getProjectID()) { throw new ArcanistUsageException( "You have installed a git pre-receive hook in a remote without an ". ".arcconfig."); } if (!$working_copy->getConfig('remote_hooks_installed')) { echo phutil_console_wrap( "\n". "NOTE: Arcanist is installed as a git pre-receive hook in the git ". "remote you are pushing to, but the project's '.arcconfig' does not ". "have the 'remote_hooks_installed' flag set. Until you set the flag, ". "some code will run needlessly in both the local and remote, and ". "revisions will be marked 'committed' in Differential when they are ". "amended rather than when they are actually pushed to the remote ". "origin.". "\n\n"); } // Git repositories have special rules in pre-receive hooks. We need to // construct the API against the .git directory instead of the project // root or commands don't work properly. $repository_api = ArcanistGitAPI::newHookAPI($_SERVER['PWD']); $root = $working_copy->getProjectRoot(); $parser = new ArcanistDiffParser(); $mark_revisions = array(); $stdin = file_get_contents('php://stdin'); $commits = array_filter(explode("\n", $stdin)); foreach ($commits as $commit) { list($old_ref, $new_ref, $refname) = explode(' ', $commit); list($log) = execx( '(cd %s && git log -n1 %s)', $repository_api->getPath(), $new_ref); $message_log = reset($parser->parseDiff($log)); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $message_log->getMetadata('message')); $revision_id = $message->getRevisionID(); if ($revision_id) { $mark_revisions[] = $revision_id; } // TODO: Do commit message junk. $info = $repository_api->getPreReceiveHookStatus($old_ref, $new_ref); $paths = ipull($info, 'mask'); $frefs = ipull($info, 'ref'); $data = array(); foreach ($paths as $path => $mask) { list($stdout) = execx( '(cd %s && git cat-file blob %s)', $repository_api->getPath(), $frefs[$path]); $data[$path] = $stdout; } // TODO: Do commit content junk. $commit_name = $new_ref; if ($revision_id) { $commit_name = 'D'.$revision_id.' ('.$commit_name.')'; } echo "[arc pre-receive] {$commit_name} OK...\n"; } $conduit = $this->getConduit(); $futures = array(); foreach ($mark_revisions as $revision_id) { $futures[] = $conduit->callMethod( 'differential.markcommitted', array( 'revision_id' => $revision_id, )); } Futures($futures)->resolveAll(); return 0; } } diff --git a/src/workflow/help/ArcanistHelpWorkflow.php b/src/workflow/help/ArcanistHelpWorkflow.php index 7ebb9ac3..5d5d7760 100644 --- a/src/workflow/help/ArcanistHelpWorkflow.php +++ b/src/workflow/help/ArcanistHelpWorkflow.php @@ -1,172 +1,177 @@ 'command', ); } public function run() { $arc_config = $this->getArcanistConfiguration(); $workflows = $arc_config->buildAllWorkflows(); ksort($workflows); $target = null; if ($this->getArgument('command')) { $target = reset($this->getArgument('command')); if (empty($workflows[$target])) { throw new ArcanistUsageException( "Unrecognized command '{$target}'. Try 'arc help'."); } } $cmdref = array(); foreach ($workflows as $command => $workflow) { if ($target && $target != $command) { continue; } $optref = array(); $arguments = $workflow->getArguments(); $config_arguments = $arc_config->getCustomArgumentsForCommand($command); // This juggling is to put the extension arguments after the normal // arguments, and make sure the normal arguments aren't overwritten. ksort($arguments); ksort($config_arguments); foreach ($config_arguments as $argument => $spec) { if (empty($arguments[$argument])) { $arguments[$argument] = $spec; } } foreach ($arguments as $argument => $spec) { if ($argument == '*') { continue; } if (isset($spec['param'])) { if (isset($spec['short'])) { $optref[] = phutil_console_format( " __--%s__ __%s__, __-%s__ __%s__", $argument, $spec['param'], $spec['short'], $spec['param']); } else { $optref[] = phutil_console_format( " __--%s__ __%s__", $argument, $spec['param']); } } else { if (isset($spec['short'])) { $optref[] = phutil_console_format( " __--%s__, __-%s__", $argument, $spec['short']); } else { $optref[] = phutil_console_format( " __--%s__", $argument); } } if (isset($config_arguments[$argument])) { $optref[] = " (This is a custom option for this ". "project.)"; } if (isset($spec['supports'])) { $optref[] = " Supports: ". implode(', ', $spec['supports']); } if (isset($spec['help'])) { $docs = $spec['help']; } else { $docs = 'This option is not documented.'; } $docs = phutil_console_wrap($docs, 14); $optref[] = " {$docs}\n"; } if ($optref) { $optref = implode("\n", $optref); $optref = "\n\n".$optref; } else { $optref = "\n"; } $cmdref[] = $workflow->getCommandHelp().$optref; } $cmdref = implode("\n\n", $cmdref); if ($target) { echo "\n".$cmdref."\n"; return; } $self = 'arc'; echo phutil_console_format(<<shouldAmendChanges = $should_amend; return $this; } public function getCommandHelp() { return phutil_console_format(<< array( 'help' => "Show all lint warnings, not just those on changed lines." ), 'summary' => array( 'help' => "Show lint warnings in a more compact format." ), 'advice' => array( 'help' => "Show lint advice, not just warnings and errors." ), 'engine' => array( 'param' => 'classname', 'help' => "Override configured lint engine for this project." ), 'apply-patches' => array( 'help' => 'Apply patches suggested by lint to the working copy without '. 'prompting.', 'conflicts' => array( 'never-apply-patches' => true, ), ), 'never-apply-patches' => array( 'help' => 'Never apply patches suggested by lint.', 'conflicts' => array( 'apply-patches' => true, ), ), '*' => 'paths', ); } public function requiresWorkingCopy() { return true; } public function run() { $working_copy = $this->getWorkingCopy(); $engine = $this->getArgument('engine'); if (!$engine) { $engine = $working_copy->getConfig('lint_engine'); } $should_lint_all = $this->getArgument('lintall'); $repository_api = null; if (!$should_lint_all) { try { $repository_api = ArcanistRepositoryAPI::newAPIFromWorkingCopyIdentity( $working_copy); $this->setRepositoryAPI($repository_api); } catch (ArcanistUsageException $ex) { throw new ArcanistUsageException( $ex->getMessage()."\n\n". "Use '--lintall' to ignore working copy changes when running lint."); } if ($repository_api instanceof ArcanistSubversionAPI) { $paths = $repository_api->getWorkingCopyStatus(); $list = new FileList($this->getArgument('paths')); foreach ($paths as $path => $flags) { if (!$list->contains($path)) { unset($paths[$path]); } } } else { $this->parseGitRelativeCommit( $repository_api, $this->getArgument('paths')); $paths = $repository_api->getWorkingCopyStatus(); } foreach ($paths as $path => $flags) { if ($flags & ArcanistRepositoryAPI::FLAG_UNTRACKED) { unset($paths[$path]); } } $paths = array_keys($paths); } else { $paths = $this->getArgument('paths'); if (empty($paths)) { throw new ArcanistUsageException( "You must specify one or more files to lint when using '--lintall'."); } } if (!$engine) { throw new ArcanistNoEngineException( "No lint engine configured for this project. Edit .arcconfig to ". "specify a lint engine."); } PhutilSymbolLoader::loadClass($engine); $engine = newv($engine, array()); $engine->setWorkingCopy($working_copy); if ($this->getArgument('advice')) { $engine->setMinimumSeverity(ArcanistLintSeverity::SEVERITY_ADVICE); } else { $engine->setMinimumSeverity(ArcanistLintSeverity::SEVERITY_WARNING); } $engine->setPaths($paths); if (!$should_lint_all) { foreach ($paths as $path) { $engine->setPathChangedLines( $path, $this->getChangedLines($path, 'new')); } } $results = $engine->run(); if ($this->getArgument('never-apply-patches')) { $apply_patches = false; } else { $apply_patches = true; } if ($this->getArgument('apply-patches')) { $prompt_patches = false; } else { $prompt_patches = true; } $wrote_to_disk = false; $renderer = new ArcanistLintRenderer(); if ($this->getArgument('summary')) { $renderer->setSummaryMode(true); } foreach ($results as $result) { if (!$result->getMessages()) { continue; } echo $renderer->renderLintResult($result); if ($apply_patches && $result->isPatchable()) { $patcher = ArcanistLintPatcher::newFromArcanistLintResult($result); $old = $patcher->getUnmodifiedFileContent(); $new = $patcher->getModifiedFileContent(); if ($prompt_patches) { $old_file = $result->getFilePathOnDisk(); if (!Filesystem::pathExists($old_file)) { $old_file = '/dev/null'; } $new_file = new TempFile(); Filesystem::writeFile($new_file, $new); // TODO: Improve the behavior here, make it more like // difference_render(). passthru(csprintf("diff -u %s %s", $old_file, $new_file)); $prompt = phutil_console_format( "Apply this patch to __%s__?", $result->getPath()); if (!phutil_console_confirm($prompt, $default_no = false)) { continue; } } $patcher->writePatchToDisk(); $wrote_to_disk = true; } } if ($wrote_to_disk && ($repository_api instanceof ArcanistGitAPI) && $this->shouldAmendChanges) { $amend = phutil_console_confirm("Amend HEAD with lint patches?"); if ($amend) { execx( '(cd %s; git commit -a --amend -C HEAD)', $repository_api->getPath()); } else { throw new ArcanistUsageException( "Sort out the lint changes that were applied to the working ". "copy and relint."); } } $unresolved = array(); $result_code = self::RESULT_OKAY; foreach ($results as $result) { foreach ($result->getMessages() as $message) { if (!$message->isPatchApplied()) { if ($message->isError()) { $result_code = self::RESULT_ERRORS; break; } else if ($message->isWarning()) { if ($result_code != self::RESULT_ERRORS) { $result_code = self::RESULT_WARNINGS; } $unresolved[] = $message; } } } } $this->unresolvedMessages = $unresolved; if (!$this->getParentWorkflow()) { if ($result_code == self::RESULT_OKAY) { echo phutil_console_format( "** OKAY ** No lint warnings.\n"); } } return $result_code; } public function getUnresolvedMessages() { return $this->unresolvedMessages; } } diff --git a/src/workflow/list/ArcanistListWorkflow.php b/src/workflow/list/ArcanistListWorkflow.php index 327cb45d..19312b24 100644 --- a/src/workflow/list/ArcanistListWorkflow.php +++ b/src/workflow/list/ArcanistListWorkflow.php @@ -1,81 +1,86 @@ getConduit(); $repository_api = $this->getRepositoryAPI(); $revision_future = $conduit->callMethod( 'differential.find', array( 'guids' => array($this->getUserGUID()), 'query' => 'open', )); $revisions = array(); foreach ($revision_future->resolve() as $revision_dict) { $revisions[] = ArcanistDifferentialRevisionRef::newFromDictionary( $revision_dict); } if (!$revisions) { echo "You have no open Differential revisions.\n"; return 0; } foreach ($revisions as $revision) { $revision_path = Filesystem::resolvePath($revision->getSourcePath()); $current_path = Filesystem::resolvePath($repository_api->getPath()); $from_here = ($revision_path == $current_path); printf( " %15s | %s | D%d | %s\n", $revision->getStatusName(), $from_here ? '*' : ' ', $revision->getID(), $revision->getName()); } return 0; } } diff --git a/src/workflow/mark-committed/ArcanistMarkCommittedWorkflow.php b/src/workflow/mark-committed/ArcanistMarkCommittedWorkflow.php index 6aa64a53..483de58e 100644 --- a/src/workflow/mark-committed/ArcanistMarkCommittedWorkflow.php +++ b/src/workflow/mark-committed/ArcanistMarkCommittedWorkflow.php @@ -1,98 +1,103 @@ 'revision', ); } public function requiresConduit() { return true; } public function requiresAuthentication() { return true; } public function run() { $conduit = $this->getConduit(); $revision_list = $this->getArgument('revision', array()); if (!$revision_list) { throw new ArcanistUsageException( "mark-committed requires a revision number."); } if (count($revision_list) != 1) { throw new ArcanistUsageException( "mark-committed requires exactly one revision."); } $revision_data = $conduit->callMethodSynchronous( 'differential.find', array( 'query' => 'committable', 'guids' => array( $this->getUserGUID(), ), )); try { $revision_id = reset($revision_list); $revision_id = $this->normalizeRevisionID($revision_id); $revision = $this->chooseRevision( $revision_data, $revision_id); } catch (ArcanistChooseInvalidRevisionException $ex) { throw new ArcanistUsageException( "Revision D{$revision_id} is not committable. You can only mark ". "revisions which have been 'accepted' as committed."); } $revision_id = $revision->getID(); $revision_name = $revision->getName(); echo "Marking revision D{$revision_id} '{$revision_name}' committed...\n"; $conduit->callMethodSynchronous( 'differential.markcommitted', array( 'revision_id' => $revision_id, )); echo "Done.\n"; return 0; } } diff --git a/src/workflow/patch/ArcanistPatchWorkflow.php b/src/workflow/patch/ArcanistPatchWorkflow.php index e203df60..241684d5 100644 --- a/src/workflow/patch/ArcanistPatchWorkflow.php +++ b/src/workflow/patch/ArcanistPatchWorkflow.php @@ -1,334 +1,339 @@ array( 'param' => 'revision_id', 'paramtype' => 'complete', 'help' => "Apply changes from a Differential revision, using the most recent ". "diff that has been attached to it.", ), 'diff' => array( 'param' => 'diff_id', 'help' => "Apply changes from a Differential diff. Normally you want to use ". "--revision to get the most recent changes, but you can ". "specifically apply an out-of-date diff or a diff which was never ". "attached to a revision by using this flag.", ), 'arcbundle' => array( 'param' => 'bundlefile', 'paramtype' => 'file', 'help' => "Apply changes from an arc bundle generated with 'arc export'.", ), 'patch' => array( 'param' => 'patchfile', 'paramtype' => 'file', 'help' => "Apply changes from a git patchfile or unified patchfile.", ), ); } protected function didParseArguments() { $source = null; $requested = 0; if ($this->getArgument('revision')) { $source = self::SOURCE_REVISION; $requested++; } if ($this->getArgument('diff')) { $source = self::SOURCE_DIFF; $requested++; } if ($this->getArgument('arcbundle')) { $source = self::SOURCE_BUNDLE; $requested++; } if ($this->getArgument('patch')) { $source = self::SOURCE_PATCH; $requested++; } if ($requested === 0) { throw new ArcanistUsageException( "Specify one of '--revision ' (to select the current ". "changes attached to a Differential revision), '--diff ' ". "(to select a specific, out-of-date diff or a diff which is not ". "attached to a revision), '--arcbundle ' or '--patch ' ". "to choose a patch source."); } else if ($requested > 1) { throw new ArcanistUsageException( "Options '--revision', '--diff', '--arcbundle' and '--patch' are ". "not compatible. Choose exactly one patch source."); } $this->source = $source; $this->sourceParam = $this->getArgument($source); } public function requiresConduit() { return ($this->getSource() == self::SOURCE_REVISION) || ($this->getSource() == self::SOURCE_DIFF); } public function requiresAuthentication() { return $this->requiresConduit(); } public function requiresRepositoryAPI() { return true; } public function requiresWorkingCopy() { return true; } private function getSource() { return $this->source; } private function getSourceParam() { return $this->sourceParam; } public function run() { $source = $this->getSource(); $param = $this->getSourceParam(); switch ($source) { case self::SOURCE_PATCH: if ($param == '-') { $patch = @file_get_contents('php://stdin'); if (!strlen($patch)) { throw new ArcanistUsageException( "Failed to read patch from stdin!"); } } else { $patch = Filesystem::readFile($param); } $bundle = ArcanistBundle::newFromDiff($patch); break; case self::SOURCE_BUNDLE: $path = $this->getArgument('arcbundle'); $bundle = ArcanistBundle::newFromArcBundle($path); break; case self::SOURCE_REVISION: $bundle = $this->loadRevisionBundleFromConduit( $this->getConduit(), $param); break; case self::SOURCE_DIFF: $bundle = $this->loadDiffBundleFromConduit( $this->getConduit(), $param); break; } $repository_api = $this->getRepositoryAPI(); if ($repository_api instanceof ArcanistSubversionAPI) { $patch_err = 0; $copies = array(); $deletes = array(); $patches = array(); $propset = array(); $adds = array(); $changes = $bundle->getChanges(); foreach ($changes as $change) { $type = $change->getType(); $should_patch = true; switch ($type) { case ArcanistDiffChangeType::TYPE_MOVE_AWAY: case ArcanistDiffChangeType::TYPE_MULTICOPY: case ArcanistDiffChangeType::TYPE_DELETE: $path = $change->getCurrentPath(); $fpath = $repository_api->getPath($path); if (!@file_exists($fpath)) { $this->confirm( "Patch deletes file '{$path}', but the file does not exist in ". "the working copy. Continue anyway?"); } else { $deletes[] = $change->getCurrentPath(); } $should_patch = false; break; case ArcanistDiffChangeType::TYPE_COPY_HERE: case ArcanistDiffChangeType::TYPE_MOVE_HERE: $path = $change->getOldPath(); $fpath = $repository_api->getPath($path); if (!@file_exists($fpath)) { $cpath = $change->getCurrentPath(); if ($type == ArcanistDiffChangeType::TYPE_COPY_HERE) { $verbs = 'copies'; } else { $verbs = 'moves'; } $this->confirm( "Patch {$verbs} '{$path}' to '{$cpath}', but source path ". "does not exist in the working copy. Continue anyway?"); } else { $copies[] = array( $change->getOldPath(), $change->getCurrentPath()); } break; case ArcanistDiffChangeType::TYPE_ADD: $adds[] = $change->getCurrentPath(); break; } if ($should_patch) { if ($change->getHunks()) { $cbundle = ArcanistBundle::newFromChanges(array($change)); $patches[$change->getCurrentPath()] = $cbundle->toUnifiedDiff(); } $prop_old = $change->getOldProperties(); $prop_new = $change->getNewProperties(); $props = $prop_old + $prop_new; foreach ($props as $key => $ignored) { if (idx($prop_old, $key) !== idx($prop_new, $key)) { $propset[$change->getCurrentPath()][$key] = idx($prop_new, $key); } } } } foreach ($copies as $copy) { list($src, $dst) = $copy; passthru( csprintf( '(cd %s; svn cp %s %s)', $repository_api->getPath(), $src, $dst)); } foreach ($deletes as $delete) { passthru( csprintf( '(cd %s; svn rm %s)', $repository_api->getPath(), $delete)); } foreach ($patches as $path => $patch) { $tmp = new TempFile(); Filesystem::writeFile($tmp, $patch); $err = null; passthru( csprintf( '(cd %s; patch -p0 < %s)', $repository_api->getPath(), $tmp), $err); if ($err) { $patch_err = max($patch_err, $err); } } foreach ($adds as $add) { passthru( csprintf( '(cd %s; svn add %s)', $repository_api->getPath(), $add)); } foreach ($propset as $path => $changes) { foreach ($change as $prop => $value) { // TODO: Probably need to handle svn:executable specially here by // doing chmod +x or -x. if ($value === null) { passthru( csprintf( '(cd %s; svn propdel %s %s)', $repository_api->getPath(), $prop, $path)); } else { passthru( csprintf( '(cd %s; svn propset %s %s %s)', $repository_api->getPath(), $prop, $value, $path)); } } } if ($patch_err == 0) { echo phutil_console_format( "** OKAY ** Successfully applied patch to the ". "working copy.\n"); } else { echo phutil_console_format( "\n\n** WARNING ** Some hunks could not be applied ". "cleanly by the unix 'patch' utility. Your working copy may be ". "different from the revision's base, or you may be in the wrong ". "subdirectory. You can export the raw patch file using ". "'arc export --unified', and then try to apply it by fiddling with ". "options to 'patch' (particularly, -p), or manually. The output ". "above, from 'patch', may be helpful in figuring out what went ". "wrong.\n"); } return $patch_err; } else { $future = new ExecFuture( '(cd %s; git apply --index)', $repository_api->getPath()); $future->write($bundle->toGitPatch()); $future->resolvex(); echo phutil_console_format( "** OKAY ** Successfully applied patch.\n"); } return 0; } public function getShellCompletions(array $argv) { // TODO: Pull open diffs from 'arc list'? return array('ARGUMENT'); } } diff --git a/src/workflow/shell-complete/ArcanistShellCompleteWorkflow.php b/src/workflow/shell-complete/ArcanistShellCompleteWorkflow.php index b2973752..238e2dd7 100644 --- a/src/workflow/shell-complete/ArcanistShellCompleteWorkflow.php +++ b/src/workflow/shell-complete/ArcanistShellCompleteWorkflow.php @@ -1,159 +1,164 @@ array( 'help' => 'Current term in the argument list being completed.', 'param' => 'cursor_position', ), '*' => 'argv', ); } public function shouldShellComplete() { return false; } public function run() { $pos = $this->getArgument('current'); $argv = $this->getArgument('argv', array()); $argc = count($argv); if ($pos === null) { $pos = $argc - 1; } // Determine which revision control system the working copy uses, so we // can filter out commands and flags which aren't supported. If we can't // figure it out, just return all flags/commands. $vcs = null; // We have to build our own because if we requiresWorkingCopy() we'll throw // if we aren't in a .arcconfig directory. We probably still can't do much, // but commands can raise more detailed errors. $working_copy = ArcanistWorkingCopyIdentity::newFromPath($_SERVER['PWD']); if ($working_copy->getProjectRoot()) { $repository_api = ArcanistRepositoryAPI::newAPIFromWorkingCopyIdentity( $working_copy); $vcs = $repository_api->getSourceControlSystemName(); } $arc_config = $this->getArcanistConfiguration(); if ($pos == 1) { $workflows = $arc_config->buildAllWorkflows(); $complete = array(); foreach ($workflows as $name => $workflow) { if (!$workflow->shouldShellComplete()) { continue; } $supported = $workflow->getSupportedRevisionControlSystems(); $ok = (in_array('any', $supported)) || (in_array($vcs, $supported)); if (!$ok) { continue; } $complete[] = $name; } echo implode(' ', $complete)."\n"; return 0; } else { $workflow = $arc_config->buildWorkflow($argv[1]); if (!$workflow) { return 1; } $arguments = $workflow->getArguments(); $prev = idx($argv, $pos - 1, null); if (!strncmp($prev, '--', 2)) { $prev = substr($prev, 2); } else { $prev = null; } if ($prev !== null && isset($arguments[$prev]) && isset($arguments[$prev]['param'])) { $type = idx($arguments[$prev], 'paramtype'); switch ($type) { case 'file': echo "FILE\n"; break; case 'complete': echo implode(' ', $workflow->getShellCompletions($argv))."\n"; break; default: echo "ARGUMENT\n"; break; } return 0; } else { $output = array(); foreach ($arguments as $argument => $spec) { if ($argument == '*') { continue; } if ($vcs && isset($spec['supports']) && !in_array($vcs, $spec['supports'])) { continue; } $output[] = '--'.$argument; } $cur = idx($argv, $pos, ''); $any_match = false; foreach ($output as $possible) { if (!strncmp($possible, $cur, strlen($cur))) { $any_match = true; } } if (!$any_match && isset($arguments['*'])) { // TODO: the '*' specifier should probably have more details about // whether or not it is a list of files. Since it almost always is in // practice, assume FILE for now. echo "FILE\n"; } else { echo implode(' ', $output)."\n"; } return 0; } } } } diff --git a/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php b/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php index 398920f6..68620928 100644 --- a/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php +++ b/src/workflow/svn-hook-pre-commit/ArcanistSvnHookPreCommitWorkflow.php @@ -1,238 +1,243 @@ 'svnargs', ); } public function shouldShellComplete() { return false; } public function run() { $svnargs = $this->getArgument('svnargs'); $repository = $svnargs[0]; $transaction = $svnargs[1]; list($commit_message) = execx( 'svnlook log --transaction %s %s', $transaction, $repository); if (strpos($commit_message, '@bypass-lint') !== false) { return 0; } // TODO: Do stuff with commit message. list($changed) = execx( 'svnlook changed --transaction %s %s', $transaction, $repository); $paths = array(); $changed = explode("\n", trim($changed)); foreach ($changed as $line) { $matches = null; preg_match('/^..\s*(.*)$/', $line, $matches); $paths[$matches[1]] = strlen($matches[1]); } $resolved = array(); $failed = array(); $missing = array(); $found = array(); asort($paths); foreach ($paths as $path => $length) { foreach ($resolved as $rpath => $root) { if (!strncmp($path, $rpath, strlen($rpath))) { $resolved[$path] = $root; continue 2; } } $config = $path; if (basename($config) == '.arcconfig') { $resolved[$config] = $config; continue; } $config = rtrim($config, '/'); $last_config = $config; do { if (!empty($missing[$config])) { break; } else if (!empty($found[$config])) { $resolved[$path] = $found[$config]; break; } list($err) = exec_manual( 'svnlook cat --transaction %s %s %s', $transaction, $repository, $config ? $config.'/.arcconfig' : '.arcconfig'); if ($err) { $missing[$path] = true; } else { $resolved[$path] = $config ? $config.'/.arcconfig' : '.arcconfig'; $found[$config] = $resolved[$path]; } $config = dirname($config); if ($config == '.') { $config = ''; } if ($config == $last_config) { break; } $last_config = $config; } while (true); if (empty($resolved[$path])) { $failed[] = $path; } } if ($failed && $resolved) { $failed_paths = ' '.implode("\n ", $failed); $resolved_paths = ' '.implode("\n ", array_keys($resolved)); throw new ArcanistUsageException( "This commit includes a mixture of files in Arcanist projects and ". "outside of Arcanist projects. A commit which affects an Arcanist ". "project must affect only that project.\n\n". "Files in projects:\n\n". $resolved_paths."\n\n". "Files not in projects:\n\n". $failed_paths); } if (!$resolved) { // None of the affected paths are beneath a .arcconfig file. return 0; } $groups = array(); foreach ($resolved as $path => $project) { $groups[$project][] = $path; } if (count($groups) > 1) { $message = array(); foreach ($groups as $config => $group) { $message[] = "Files underneath '{$config}':\n\n"; $message[] = " ".implode("\n ", $group)."\n\n"; } $message = implode('', $message); throw new ArcanistUsageException( "This commit includes a mixture of files from different Arcanist ". "projects. A commit which affects an Arcanist project must affect ". "only that project.\n\n". $message); } $config_file = key($groups); $project_root = dirname($config_file); $paths = reset($groups); list($config) = execx( 'svnlook cat --transaction %s %s %s', $transaction, $repository, $config_file); $data = array(); foreach ($paths as $path) { // TODO: This should be done in parallel. list($err, $filedata) = exec_manual( 'svnlook cat --transaction %s %s %s', $transaction, $repository, $path); $data[$path] = $err ? null : $filedata; } $working_copy = ArcanistWorkingCopyIdentity::newFromRootAndConfigFile( $project_root, $config); $lint_engine = $working_copy->getConfig('lint_engine'); if (!$lint_engine) { return 0; } PhutilSymbolLoader::loadClass($lint_engine); $engine = newv($lint_engine, array()); $engine->setWorkingCopy($working_copy); $engine->setMinimumSeverity(ArcanistLintSeverity::SEVERITY_ERROR); $engine->setPaths(array_keys($data)); $engine->setFileData($data); $engine->setCommitHookMode(true); try { $results = $engine->run(); } catch (ArcanistNoEffectException $no_effect) { // Nothing to do, bail out. return 0; } $renderer = new ArcanistLintRenderer(); $failures = array(); foreach ($results as $result) { if (!$result->getMessages()) { continue; } $failures[] = $result; } if ($failures) { $at = "@"; $msg = phutil_console_format( "\n**LINT ERRORS**\n\n". "This changeset has lint errors. You must fix all lint errors before ". "you can commit.\n\n". "You can add '{$at}bypass-lint' to your commit message to disable ". "lint checks for this commit, or '{$at}nolint' to the file with ". "errors to disable lint for that file.\n\n"); echo phutil_console_wrap($msg); foreach ($failures as $result) { echo $renderer->renderLintResult($result); } return 1; } return 0; } } diff --git a/src/workflow/unit/ArcanistUnitWorkflow.php b/src/workflow/unit/ArcanistUnitWorkflow.php index 71488138..307bc0ea 100644 --- a/src/workflow/unit/ArcanistUnitWorkflow.php +++ b/src/workflow/unit/ArcanistUnitWorkflow.php @@ -1,140 +1,145 @@ array( 'param' => 'classname', 'help' => "Override configured unit engine for this project." ), '*' => 'paths', ); } public function requiresWorkingCopy() { return true; } public function requiresRepositoryAPI() { return true; } public function run() { $working_copy = $this->getWorkingCopy(); $engine_class = $this->getArgument( 'engine', $working_copy->getConfig('unit_engine')); if (!$engine_class) { throw new ArcanistNoEngineException( "No unit test engine is configured for this project. Edit .arcconfig ". "to specify a unit test engine."); } $repository_api = $this->getRepositoryAPI(); if ($this->getArgument('paths')) { // TODO: deal with git stuff $paths = $this->getArgument('paths'); } else { $paths = $repository_api->getWorkingCopyStatus(); // TODO: clean this up foreach ($paths as $path => $mask) { if ($mask & ArcanistRepositoryAPI::FLAG_UNTRACKED) { unset($paths[$path]); } } $paths = array_keys($paths); } PhutilSymbolLoader::loadClass($engine_class); $engine = newv($engine_class, array()); $engine->setWorkingCopy($working_copy); $engine->setPaths($paths); $engine->setArguments($this->getPassthruArgumentsAsMap('unit')); $results = $engine->run(); $status_codes = array( ArcanistUnitTestResult::RESULT_PASS => phutil_console_format( ' ** PASS **'), ArcanistUnitTestResult::RESULT_FAIL => phutil_console_format( ' ** FAIL **'), ArcanistUnitTestResult::RESULT_SKIP => phutil_console_format( ' ** SKIP **'), ArcanistUnitTestResult::RESULT_BROKEN => phutil_console_format( ' ** BROKEN **'), ArcanistUnitTestResult::RESULT_UNSOUND => phutil_console_format( ' ** UNSOUND **'), ); $unresolved = array(); foreach ($results as $result) { $result_code = $result->getResult(); echo $status_codes[$result_code].' '.$result->getName()."\n"; if ($result_code != ArcanistUnitTestResult::RESULT_PASS) { echo $result->getUserData()."\n"; $unresolved[] = $result; } } $this->unresolvedTests = $unresolved; $overall_result = self::RESULT_OKAY; foreach ($results as $result) { $result_code = $result->getResult(); if ($result_code == ArcanistUnitTestResult::RESULT_FAIL || $result_code == ArcanistUnitTestResult::RESULT_BROKEN) { $overall_result = self::RESULT_FAIL; break; } else if ($result_code == ArcanistUnitTestResult::RESULT_UNSOUND) { $overall_result = self::RESULT_UNSOUND; } } return $overall_result; } public function getUnresolvedTests() { return $this->unresolvedTests; } } diff --git a/src/workflow/unit/__init__.php b/src/workflow/unit/__init__.php index b408e2ae..3b791827 100644 --- a/src/workflow/unit/__init__.php +++ b/src/workflow/unit/__init__.php @@ -1,18 +1,19 @@ projectRoot = $root; $this->projectConfig = $config; } public function getProjectID() { return $this->getConfig('project_id'); } public function getProjectRoot() { return $this->projectRoot; } public function getConduitURI() { return $this->getConfig('conduit_uri'); } public function getConfig($key) { if (!empty($this->projectConfig[$key])) { return $this->projectConfig[$key]; } return null; } }