diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index ebd21156..a7ca5c09 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1,346 +1,348 @@ 2, 'class' => array( 'ArcanistAliasWorkflow' => 'workflow/ArcanistAliasWorkflow.php', 'ArcanistAmendWorkflow' => 'workflow/ArcanistAmendWorkflow.php', 'ArcanistAnoidWorkflow' => 'workflow/ArcanistAnoidWorkflow.php', 'ArcanistArcanistLinterTestCase' => 'lint/linter/__tests__/ArcanistArcanistLinterTestCase.php', 'ArcanistBackoutWorkflow' => 'workflow/ArcanistBackoutWorkflow.php', 'ArcanistBaseCommitParser' => 'parser/ArcanistBaseCommitParser.php', 'ArcanistBaseCommitParserTestCase' => 'parser/__tests__/ArcanistBaseCommitParserTestCase.php', 'ArcanistBaseTestResultParser' => 'unit/engine/ArcanistBaseTestResultParser.php', 'ArcanistBaseUnitTestEngine' => 'unit/engine/ArcanistBaseUnitTestEngine.php', 'ArcanistBaseWorkflow' => 'workflow/ArcanistBaseWorkflow.php', 'ArcanistBaseXHPASTLinter' => 'lint/linter/ArcanistBaseXHPASTLinter.php', 'ArcanistBookmarkWorkflow' => 'workflow/ArcanistBookmarkWorkflow.php', 'ArcanistBranchWorkflow' => 'workflow/ArcanistBranchWorkflow.php', 'ArcanistBritishTestCase' => 'configuration/__tests__/ArcanistBritishTestCase.php', 'ArcanistBrowseWorkflow' => 'workflow/ArcanistBrowseWorkflow.php', 'ArcanistBundle' => 'parser/ArcanistBundle.php', 'ArcanistBundleTestCase' => 'parser/__tests__/ArcanistBundleTestCase.php', 'ArcanistCSSLintLinter' => 'lint/linter/ArcanistCSSLintLinter.php', 'ArcanistCSSLintLinterTestCase' => 'lint/linter/__tests__/ArcanistCSSLintLinterTestCase.php', 'ArcanistCSharpLinter' => 'lint/linter/ArcanistCSharpLinter.php', 'ArcanistCallConduitWorkflow' => 'workflow/ArcanistCallConduitWorkflow.php', 'ArcanistCapabilityNotSupportedException' => 'workflow/exception/ArcanistCapabilityNotSupportedException.php', 'ArcanistChooseInvalidRevisionException' => 'exception/ArcanistChooseInvalidRevisionException.php', 'ArcanistChooseNoRevisionsException' => 'exception/ArcanistChooseNoRevisionsException.php', 'ArcanistCloseRevisionWorkflow' => 'workflow/ArcanistCloseRevisionWorkflow.php', 'ArcanistCloseWorkflow' => 'workflow/ArcanistCloseWorkflow.php', 'ArcanistCommentRemover' => 'parser/ArcanistCommentRemover.php', 'ArcanistCommentRemoverTestCase' => 'parser/__tests__/ArcanistCommentRemoverTestCase.php', 'ArcanistCommitWorkflow' => 'workflow/ArcanistCommitWorkflow.php', 'ArcanistConduitLinter' => 'lint/linter/ArcanistConduitLinter.php', 'ArcanistConfiguration' => 'configuration/ArcanistConfiguration.php', 'ArcanistConfigurationDrivenLintEngine' => 'lint/engine/ArcanistConfigurationDrivenLintEngine.php', 'ArcanistConfigurationManager' => 'configuration/ArcanistConfigurationManager.php', 'ArcanistCoverWorkflow' => 'workflow/ArcanistCoverWorkflow.php', 'ArcanistCppcheckLinter' => 'lint/linter/ArcanistCppcheckLinter.php', 'ArcanistCpplintLinter' => 'lint/linter/ArcanistCpplintLinter.php', 'ArcanistCpplintLinterTestCase' => 'lint/linter/__tests__/ArcanistCpplintLinterTestCase.php', 'ArcanistDiffChange' => 'parser/diff/ArcanistDiffChange.php', 'ArcanistDiffChangeType' => 'parser/diff/ArcanistDiffChangeType.php', 'ArcanistDiffHunk' => 'parser/diff/ArcanistDiffHunk.php', 'ArcanistDiffParser' => 'parser/ArcanistDiffParser.php', 'ArcanistDiffParserTestCase' => 'parser/__tests__/ArcanistDiffParserTestCase.php', 'ArcanistDiffUtils' => 'difference/ArcanistDiffUtils.php', 'ArcanistDiffUtilsTestCase' => 'difference/__tests__/ArcanistDiffUtilsTestCase.php', 'ArcanistDiffWorkflow' => 'workflow/ArcanistDiffWorkflow.php', 'ArcanistDifferentialCommitMessage' => 'differential/ArcanistDifferentialCommitMessage.php', 'ArcanistDifferentialCommitMessageParserException' => 'differential/ArcanistDifferentialCommitMessageParserException.php', 'ArcanistDifferentialDependencyGraph' => 'differential/ArcanistDifferentialDependencyGraph.php', 'ArcanistDifferentialRevisionHash' => 'differential/constants/ArcanistDifferentialRevisionHash.php', 'ArcanistDifferentialRevisionStatus' => 'differential/constants/ArcanistDifferentialRevisionStatus.php', 'ArcanistDownloadWorkflow' => 'workflow/ArcanistDownloadWorkflow.php', 'ArcanistEventType' => 'events/constant/ArcanistEventType.php', 'ArcanistExportWorkflow' => 'workflow/ArcanistExportWorkflow.php', 'ArcanistExternalLinter' => 'lint/linter/ArcanistExternalLinter.php', 'ArcanistFeatureWorkflow' => 'workflow/ArcanistFeatureWorkflow.php', 'ArcanistFilenameLinter' => 'lint/linter/ArcanistFilenameLinter.php', 'ArcanistFlagWorkflow' => 'workflow/ArcanistFlagWorkflow.php', 'ArcanistFlake8Linter' => 'lint/linter/ArcanistFlake8Linter.php', 'ArcanistFlake8LinterTestCase' => 'lint/linter/__tests__/ArcanistFlake8LinterTestCase.php', 'ArcanistFutureLinter' => 'lint/linter/ArcanistFutureLinter.php', 'ArcanistGeneratedLinter' => 'lint/linter/ArcanistGeneratedLinter.php', 'ArcanistGetConfigWorkflow' => 'workflow/ArcanistGetConfigWorkflow.php', 'ArcanistGitAPI' => 'repository/api/ArcanistGitAPI.php', 'ArcanistGitHookPreReceiveWorkflow' => 'workflow/ArcanistGitHookPreReceiveWorkflow.php', 'ArcanistHelpWorkflow' => 'workflow/ArcanistHelpWorkflow.php', 'ArcanistHgClientChannel' => 'hgdaemon/ArcanistHgClientChannel.php', 'ArcanistHgProxyClient' => 'hgdaemon/ArcanistHgProxyClient.php', 'ArcanistHgProxyServer' => 'hgdaemon/ArcanistHgProxyServer.php', 'ArcanistHgServerChannel' => 'hgdaemon/ArcanistHgServerChannel.php', 'ArcanistHookAPI' => 'repository/hookapi/ArcanistHookAPI.php', 'ArcanistInlinesWorkflow' => 'workflow/ArcanistInlinesWorkflow.php', 'ArcanistInstallCertificateWorkflow' => 'workflow/ArcanistInstallCertificateWorkflow.php', 'ArcanistJSHintLinter' => 'lint/linter/ArcanistJSHintLinter.php', 'ArcanistJSHintLinterTestCase' => 'lint/linter/__tests__/ArcanistJSHintLinterTestCase.php', 'ArcanistJSONLintLinter' => 'lint/linter/ArcanistJSONLintLinter.php', 'ArcanistJSONLintLinterTestCase' => 'lint/linter/__tests__/ArcanistJSONLintLinterTestCase.php', 'ArcanistLandWorkflow' => 'workflow/ArcanistLandWorkflow.php', 'ArcanistLesscLinter' => 'lint/linter/ArcanistLesscLinter.php', 'ArcanistLesscLinterTestCase' => 'lint/linter/__tests__/ArcanistLesscLinterTestCase.php', 'ArcanistLiberateWorkflow' => 'workflow/ArcanistLiberateWorkflow.php', 'ArcanistLintConsoleRenderer' => 'lint/renderer/ArcanistLintConsoleRenderer.php', 'ArcanistLintEngine' => 'lint/engine/ArcanistLintEngine.php', 'ArcanistLintJSONRenderer' => 'lint/renderer/ArcanistLintJSONRenderer.php', 'ArcanistLintLikeCompilerRenderer' => 'lint/renderer/ArcanistLintLikeCompilerRenderer.php', 'ArcanistLintMessage' => 'lint/ArcanistLintMessage.php', 'ArcanistLintNoneRenderer' => 'lint/renderer/ArcanistLintNoneRenderer.php', 'ArcanistLintPatcher' => 'lint/ArcanistLintPatcher.php', 'ArcanistLintRenderer' => 'lint/renderer/ArcanistLintRenderer.php', 'ArcanistLintResult' => 'lint/ArcanistLintResult.php', 'ArcanistLintSeverity' => 'lint/ArcanistLintSeverity.php', 'ArcanistLintSummaryRenderer' => 'lint/renderer/ArcanistLintSummaryRenderer.php', 'ArcanistLintWorkflow' => 'workflow/ArcanistLintWorkflow.php', 'ArcanistLinter' => 'lint/linter/ArcanistLinter.php', 'ArcanistLinterTestCase' => 'lint/linter/__tests__/ArcanistLinterTestCase.php', + 'ArcanistLintersWorkflow' => 'workflow/ArcanistLintersWorkflow.php', 'ArcanistListWorkflow' => 'workflow/ArcanistListWorkflow.php', 'ArcanistMarkCommittedWorkflow' => 'workflow/ArcanistMarkCommittedWorkflow.php', 'ArcanistMercurialAPI' => 'repository/api/ArcanistMercurialAPI.php', 'ArcanistMercurialParser' => 'repository/parser/ArcanistMercurialParser.php', 'ArcanistMercurialParserTestCase' => 'repository/parser/__tests__/ArcanistMercurialParserTestCase.php', 'ArcanistMergeConflictLinter' => 'lint/linter/ArcanistMergeConflictLinter.php', 'ArcanistNoEffectException' => 'exception/usage/ArcanistNoEffectException.php', 'ArcanistNoEngineException' => 'exception/usage/ArcanistNoEngineException.php', 'ArcanistNoLintLinter' => 'lint/linter/ArcanistNoLintLinter.php', 'ArcanistNoLintTestCaseMisnamed' => 'lint/linter/__tests__/ArcanistNoLintTestCase.php', 'ArcanistPEP8Linter' => 'lint/linter/ArcanistPEP8Linter.php', 'ArcanistPEP8LinterTestCase' => 'lint/linter/__tests__/ArcanistPEP8LinterTestCase.php', 'ArcanistPHPCSLinterTestCase' => 'lint/linter/__tests__/ArcanistPHPCSLinterTestCase.php', 'ArcanistPasteWorkflow' => 'workflow/ArcanistPasteWorkflow.php', 'ArcanistPatchWorkflow' => 'workflow/ArcanistPatchWorkflow.php', 'ArcanistPhpcsLinter' => 'lint/linter/ArcanistPhpcsLinter.php', 'ArcanistPhutilLibraryLinter' => 'lint/linter/ArcanistPhutilLibraryLinter.php', 'ArcanistPhutilTestCase' => 'unit/engine/phutil/ArcanistPhutilTestCase.php', 'ArcanistPhutilTestCaseTestCase' => 'unit/engine/phutil/testcase/ArcanistPhutilTestCaseTestCase.php', 'ArcanistPhutilTestSkippedException' => 'unit/engine/phutil/testcase/ArcanistPhutilTestSkippedException.php', 'ArcanistPhutilTestTerminatedException' => 'unit/engine/phutil/testcase/ArcanistPhutilTestTerminatedException.php', 'ArcanistPhutilXHPASTLinter' => 'lint/linter/ArcanistPhutilXHPASTLinter.php', 'ArcanistPhutilXHPASTLinterTestCase' => 'lint/linter/__tests__/ArcanistPhutilXHPASTLinterTestCase.php', 'ArcanistPuppetLintLinter' => 'lint/linter/ArcanistPuppetLintLinter.php', 'ArcanistPuppetLintLinterTestCase' => 'lint/linter/__tests__/ArcanistPuppetLintLinterTestCase.php', 'ArcanistPyFlakesLinter' => 'lint/linter/ArcanistPyFlakesLinter.php', 'ArcanistPyFlakesLinterTestCase' => 'lint/linter/__tests__/ArcanistPyFlakesLinterTestCase.php', 'ArcanistPyLintLinter' => 'lint/linter/ArcanistPyLintLinter.php', 'ArcanistRepositoryAPI' => 'repository/api/ArcanistRepositoryAPI.php', 'ArcanistRepositoryAPIMiscTestCase' => 'repository/api/__tests__/ArcanistRepositoryAPIMiscTestCase.php', 'ArcanistRepositoryAPIStateTestCase' => 'repository/api/__tests__/ArcanistRepositoryAPIStateTestCase.php', 'ArcanistRevertWorkflow' => 'workflow/ArcanistRevertWorkflow.php', 'ArcanistRubyLinter' => 'lint/linter/ArcanistRubyLinter.php', 'ArcanistRubyLinterTestCase' => 'lint/linter/__tests__/ArcanistRubyLinterTestCase.php', 'ArcanistScalaSBTLinter' => 'lint/linter/ArcanistScalaSBTLinter.php', 'ArcanistScriptAndRegexLinter' => 'lint/linter/ArcanistScriptAndRegexLinter.php', 'ArcanistSetConfigWorkflow' => 'workflow/ArcanistSetConfigWorkflow.php', 'ArcanistSettings' => 'configuration/ArcanistSettings.php', 'ArcanistShellCompleteWorkflow' => 'workflow/ArcanistShellCompleteWorkflow.php', 'ArcanistSingleLintEngine' => 'lint/engine/ArcanistSingleLintEngine.php', 'ArcanistSpellingDefaultData' => 'lint/linter/spelling/ArcanistSpellingDefaultData.php', 'ArcanistSpellingLinter' => 'lint/linter/ArcanistSpellingLinter.php', 'ArcanistSpellingLinterTestCase' => 'lint/linter/__tests__/ArcanistSpellingLinterTestCase.php', 'ArcanistSubversionAPI' => 'repository/api/ArcanistSubversionAPI.php', 'ArcanistSubversionHookAPI' => 'repository/hookapi/ArcanistSubversionHookAPI.php', 'ArcanistSvnHookPreCommitWorkflow' => 'workflow/ArcanistSvnHookPreCommitWorkflow.php', 'ArcanistTasksWorkflow' => 'workflow/ArcanistTasksWorkflow.php', 'ArcanistTestCase' => 'infrastructure/testing/ArcanistTestCase.php', 'ArcanistTextLinter' => 'lint/linter/ArcanistTextLinter.php', 'ArcanistTextLinterTestCase' => 'lint/linter/__tests__/ArcanistTextLinterTestCase.php', 'ArcanistTodoWorkflow' => 'workflow/ArcanistTodoWorkflow.php', 'ArcanistUncommittedChangesException' => 'exception/usage/ArcanistUncommittedChangesException.php', 'ArcanistUnitConsoleRenderer' => 'unit/renderer/ArcanistUnitConsoleRenderer.php', 'ArcanistUnitRenderer' => 'unit/renderer/ArcanistUnitRenderer.php', 'ArcanistUnitTestResult' => 'unit/ArcanistUnitTestResult.php', 'ArcanistUnitWorkflow' => 'workflow/ArcanistUnitWorkflow.php', 'ArcanistUpgradeWorkflow' => 'workflow/ArcanistUpgradeWorkflow.php', 'ArcanistUploadWorkflow' => 'workflow/ArcanistUploadWorkflow.php', 'ArcanistUsageException' => 'exception/ArcanistUsageException.php', 'ArcanistUserAbortException' => 'exception/usage/ArcanistUserAbortException.php', 'ArcanistWhichWorkflow' => 'workflow/ArcanistWhichWorkflow.php', 'ArcanistWorkingCopyIdentity' => 'workingcopyidentity/ArcanistWorkingCopyIdentity.php', 'ArcanistXHPASTLintNamingHook' => 'lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php', 'ArcanistXHPASTLintNamingHookTestCase' => 'lint/linter/xhpast/__tests__/ArcanistXHPASTLintNamingHookTestCase.php', 'ArcanistXHPASTLintSwitchHook' => 'lint/linter/xhpast/ArcanistXHPASTLintSwitchHook.php', 'ArcanistXHPASTLintTestSwitchHook' => 'lint/linter/__tests__/ArcanistXHPASTLintTestSwitchHook.php', 'ArcanistXHPASTLinter' => 'lint/linter/ArcanistXHPASTLinter.php', 'ArcanistXHPASTLinterTestCase' => 'lint/linter/__tests__/ArcanistXHPASTLinterTestCase.php', 'ArcanistXMLLinter' => 'lint/linter/ArcanistXMLLinter.php', 'ArcanistXMLLinterTestCase' => 'lint/linter/__tests__/ArcanistXMLLinterTestCase.php', 'ArcanistXUnitTestResultParser' => 'unit/engine/ArcanistXUnitTestResultParser.php', 'CSharpToolsTestEngine' => 'unit/engine/CSharpToolsTestEngine.php', 'ComprehensiveLintEngine' => 'lint/engine/ComprehensiveLintEngine.php', 'ExampleLintEngine' => 'lint/engine/ExampleLintEngine.php', 'GoTestResultParser' => 'unit/engine/GoTestResultParser.php', 'GoTestResultParserTestCase' => 'unit/engine/__tests__/GoTestResultParserTestCase.php', 'NoseTestEngine' => 'unit/engine/NoseTestEngine.php', 'PHPUnitTestEngineTestCase' => 'unit/engine/__tests__/PHPUnitTestEngineTestCase.php', 'PhpunitResultParser' => 'unit/engine/PhpunitResultParser.php', 'PhpunitTestEngine' => 'unit/engine/PhpunitTestEngine.php', 'PhutilLintEngine' => 'lint/engine/PhutilLintEngine.php', 'PhutilUnitTestEngine' => 'unit/engine/PhutilUnitTestEngine.php', 'PhutilUnitTestEngineTestCase' => 'unit/engine/__tests__/PhutilUnitTestEngineTestCase.php', 'PytestTestEngine' => 'unit/engine/PytestTestEngine.php', 'UnitTestableArcanistLintEngine' => 'lint/engine/UnitTestableArcanistLintEngine.php', 'XUnitTestEngine' => 'unit/engine/XUnitTestEngine.php', 'XUnitTestResultParserTestCase' => 'unit/engine/__tests__/XUnitTestResultParserTestCase.php', ), 'function' => array( ), 'xmap' => array( 'ArcanistAliasWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistAmendWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistAnoidWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistArcanistLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistBackoutWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistBaseCommitParserTestCase' => 'ArcanistTestCase', 'ArcanistBaseWorkflow' => 'Phobject', 'ArcanistBaseXHPASTLinter' => 'ArcanistFutureLinter', 'ArcanistBookmarkWorkflow' => 'ArcanistFeatureWorkflow', 'ArcanistBranchWorkflow' => 'ArcanistFeatureWorkflow', 'ArcanistBritishTestCase' => 'ArcanistTestCase', 'ArcanistBrowseWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistBundleTestCase' => 'ArcanistTestCase', 'ArcanistCSSLintLinter' => 'ArcanistExternalLinter', 'ArcanistCSSLintLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistCSharpLinter' => 'ArcanistLinter', 'ArcanistCallConduitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCapabilityNotSupportedException' => 'Exception', 'ArcanistChooseInvalidRevisionException' => 'Exception', 'ArcanistChooseNoRevisionsException' => 'Exception', 'ArcanistCloseRevisionWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCloseWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCommentRemoverTestCase' => 'ArcanistTestCase', 'ArcanistCommitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistConduitLinter' => 'ArcanistLinter', 'ArcanistConfigurationDrivenLintEngine' => 'ArcanistLintEngine', 'ArcanistCoverWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistCppcheckLinter' => 'ArcanistLinter', 'ArcanistCpplintLinter' => 'ArcanistExternalLinter', 'ArcanistCpplintLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistDiffParserTestCase' => 'ArcanistTestCase', 'ArcanistDiffUtilsTestCase' => 'ArcanistTestCase', 'ArcanistDiffWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistDifferentialCommitMessageParserException' => 'Exception', 'ArcanistDifferentialDependencyGraph' => 'AbstractDirectedGraph', 'ArcanistDownloadWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistEventType' => 'PhutilEventType', 'ArcanistExportWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistExternalLinter' => 'ArcanistFutureLinter', 'ArcanistFeatureWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistFilenameLinter' => 'ArcanistLinter', 'ArcanistFlagWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistFlake8Linter' => 'ArcanistExternalLinter', 'ArcanistFlake8LinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistFutureLinter' => 'ArcanistLinter', 'ArcanistGeneratedLinter' => 'ArcanistLinter', 'ArcanistGetConfigWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistGitAPI' => 'ArcanistRepositoryAPI', 'ArcanistGitHookPreReceiveWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistHelpWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistHgClientChannel' => 'PhutilProtocolChannel', 'ArcanistHgServerChannel' => 'PhutilProtocolChannel', 'ArcanistInlinesWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistInstallCertificateWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistJSHintLinter' => 'ArcanistExternalLinter', 'ArcanistJSHintLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistJSONLintLinter' => 'ArcanistExternalLinter', 'ArcanistJSONLintLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistLandWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistLesscLinter' => 'ArcanistExternalLinter', 'ArcanistLesscLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistLiberateWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistLintConsoleRenderer' => 'ArcanistLintRenderer', 'ArcanistLintJSONRenderer' => 'ArcanistLintRenderer', 'ArcanistLintLikeCompilerRenderer' => 'ArcanistLintRenderer', 'ArcanistLintNoneRenderer' => 'ArcanistLintRenderer', 'ArcanistLintSummaryRenderer' => 'ArcanistLintRenderer', 'ArcanistLintWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistLinterTestCase' => 'ArcanistPhutilTestCase', + 'ArcanistLintersWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistListWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistMarkCommittedWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistMercurialAPI' => 'ArcanistRepositoryAPI', 'ArcanistMercurialParserTestCase' => 'ArcanistTestCase', 'ArcanistMergeConflictLinter' => 'ArcanistLinter', 'ArcanistNoEffectException' => 'ArcanistUsageException', 'ArcanistNoEngineException' => 'ArcanistUsageException', 'ArcanistNoLintLinter' => 'ArcanistLinter', 'ArcanistNoLintTestCaseMisnamed' => 'ArcanistLinterTestCase', 'ArcanistPEP8Linter' => 'ArcanistExternalLinter', 'ArcanistPEP8LinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistPHPCSLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistPasteWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistPatchWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistPhpcsLinter' => 'ArcanistExternalLinter', 'ArcanistPhutilLibraryLinter' => 'ArcanistLinter', 'ArcanistPhutilTestCaseTestCase' => 'ArcanistPhutilTestCase', 'ArcanistPhutilTestSkippedException' => 'Exception', 'ArcanistPhutilTestTerminatedException' => 'Exception', 'ArcanistPhutilXHPASTLinter' => 'ArcanistBaseXHPASTLinter', 'ArcanistPhutilXHPASTLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistPuppetLintLinter' => 'ArcanistExternalLinter', 'ArcanistPuppetLintLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistPyFlakesLinter' => 'ArcanistExternalLinter', 'ArcanistPyFlakesLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistPyLintLinter' => 'ArcanistLinter', 'ArcanistRepositoryAPIMiscTestCase' => 'ArcanistTestCase', 'ArcanistRepositoryAPIStateTestCase' => 'ArcanistTestCase', 'ArcanistRevertWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistRubyLinter' => 'ArcanistExternalLinter', 'ArcanistRubyLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistScalaSBTLinter' => 'ArcanistLinter', 'ArcanistScriptAndRegexLinter' => 'ArcanistLinter', 'ArcanistSetConfigWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistShellCompleteWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistSingleLintEngine' => 'ArcanistLintEngine', 'ArcanistSpellingLinter' => 'ArcanistLinter', 'ArcanistSpellingLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistSubversionAPI' => 'ArcanistRepositoryAPI', 'ArcanistSubversionHookAPI' => 'ArcanistHookAPI', 'ArcanistSvnHookPreCommitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistTasksWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistTestCase' => 'ArcanistPhutilTestCase', 'ArcanistTextLinter' => 'ArcanistLinter', 'ArcanistTextLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistTodoWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistUncommittedChangesException' => 'ArcanistUsageException', 'ArcanistUnitConsoleRenderer' => 'ArcanistUnitRenderer', 'ArcanistUnitWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistUpgradeWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistUploadWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistUsageException' => 'Exception', 'ArcanistUserAbortException' => 'ArcanistUsageException', 'ArcanistWhichWorkflow' => 'ArcanistBaseWorkflow', 'ArcanistXHPASTLintNamingHookTestCase' => 'ArcanistTestCase', 'ArcanistXHPASTLintTestSwitchHook' => 'ArcanistXHPASTLintSwitchHook', 'ArcanistXHPASTLinter' => 'ArcanistBaseXHPASTLinter', 'ArcanistXHPASTLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'ArcanistXMLLinter' => 'ArcanistLinter', 'ArcanistXMLLinterTestCase' => 'ArcanistArcanistLinterTestCase', 'CSharpToolsTestEngine' => 'XUnitTestEngine', 'ComprehensiveLintEngine' => 'ArcanistLintEngine', 'ExampleLintEngine' => 'ArcanistLintEngine', 'GoTestResultParser' => 'ArcanistBaseTestResultParser', 'GoTestResultParserTestCase' => 'ArcanistTestCase', 'NoseTestEngine' => 'ArcanistBaseUnitTestEngine', 'PHPUnitTestEngineTestCase' => 'ArcanistTestCase', 'PhpunitResultParser' => 'ArcanistBaseTestResultParser', 'PhpunitTestEngine' => 'ArcanistBaseUnitTestEngine', 'PhutilLintEngine' => 'ArcanistLintEngine', 'PhutilUnitTestEngine' => 'ArcanistBaseUnitTestEngine', 'PhutilUnitTestEngineTestCase' => 'ArcanistTestCase', 'PytestTestEngine' => 'ArcanistBaseUnitTestEngine', 'UnitTestableArcanistLintEngine' => 'ArcanistLintEngine', 'XUnitTestEngine' => 'ArcanistBaseUnitTestEngine', 'XUnitTestResultParserTestCase' => 'ArcanistTestCase', ), )); diff --git a/src/lint/linter/ArcanistExternalLinter.php b/src/lint/linter/ArcanistExternalLinter.php index a858a54e..fe05f0c5 100644 --- a/src/lint/linter/ArcanistExternalLinter.php +++ b/src/lint/linter/ArcanistExternalLinter.php @@ -1,522 +1,518 @@ Mandatory flags, like `"--format=xml"`. * @task bin */ protected function getMandatoryFlags() { return array(); } /** * Provide default, overridable flags to the linter. Generally these are * configuration flags which affect behavior but aren't critical. Flags * which are required should be provided in @{method:getMandatoryFlags} * instead. * * Default flags can be overridden with @{method:setFlags}. * * @return list Overridable default flags. * @task bin */ protected function getDefaultFlags() { return array(); } /** * Override default flags with custom flags. If not overridden, flags provided * by @{method:getDefaultFlags} are used. * * @param list New flags. * @return this * @task bin */ final public function setFlags($flags) { $this->flags = (array) $flags; return $this; } /** * Return the binary or script to execute. This method synthesizes defaults * and configuration. You can override the binary with @{method:setBinary}. * * @return string Binary to execute. * @task bin */ final public function getBinary() { return coalesce($this->bin, $this->getDefaultBinary()); } /** * Override the default binary with a new one. * * @param string New binary. * @return this * @task bin */ final public function setBinary($bin) { $this->bin = $bin; return $this; } /** * Return true if this linter should use an interpreter (like "python" or * "node") in addition to the script. * * After overriding this method to return `true`, override * @{method:getDefaultInterpreter} to set a default. * * @return bool True to use an interpreter. * @task bin */ public function shouldUseInterpreter() { return false; } /** * Return the default interpreter, like "python" or "node". This method is * only invoked if @{method:shouldUseInterpreter} has been overridden to * return `true`. * * @return string Default interpreter. * @task bin */ public function getDefaultInterpreter() { throw new Exception("Incomplete implementation!"); } /** * Get the effective interpreter. This method synthesizes configuration and * defaults. * * @return string Effective interpreter. * @task bin */ final public function getInterpreter() { return coalesce($this->interpreter, $this->getDefaultInterpreter()); } /** * Set the interpreter, overriding any default. * * @param string New interpreter. * @return this * @task bin */ final public function setInterpreter($interpreter) { $this->interpreter = $interpreter; return $this; } /* -( Parsing Linter Output )---------------------------------------------- */ /** * Parse the output of the external lint program into objects of class * @{class:ArcanistLintMessage} which `arc` can consume. Generally, this * means examining the output and converting each warning or error into a * message. * * If parsing fails, returning `false` will cause the caller to throw an * appropriate exception. (You can also throw a more specific exception if * you're able to detect a more specific condition.) Otherwise, return a list * of messages. * * @param string Path to the file being linted. * @param int Exit code of the linter. * @param string Stdout of the linter. * @param string Stderr of the linter. * @return list|false List of lint messages, or false * to indicate parser failure. * @task parse */ abstract protected function parseLinterOutput($path, $err, $stdout, $stderr); /* -( Executing the Linter )----------------------------------------------- */ /** * Check that the binary and interpreter (if applicable) exist, and throw * an exception with a message about how to install them if they do not. * * @return void */ final public function checkBinaryConfiguration() { $interpreter = null; if ($this->shouldUseInterpreter()) { $interpreter = $this->getInterpreter(); } $binary = $this->getBinary(); // NOTE: If we have an interpreter, we don't require the script to be // executable (so we just check that the path exists). Otherwise, the // binary must be executable. if ($interpreter) { if (!Filesystem::binaryExists($interpreter)) { throw new ArcanistUsageException( pht( 'Unable to locate interpreter "%s" to run linter %s. You may '. 'need to install the intepreter, or adjust your linter '. 'configuration.'. "\nTO INSTALL: %s", $interpreter, get_class($this), $this->getInstallInstructions())); } if (!Filesystem::pathExists($binary)) { throw new ArcanistUsageException( pht( 'Unable to locate script "%s" to run linter %s. You may need '. 'to install the script, or adjust your linter configuration. '. "\nTO INSTALL: %s", $binary, get_class($this), $this->getInstallInstructions())); } } else { if (!Filesystem::binaryExists($binary)) { throw new ArcanistUsageException( pht( 'Unable to locate binary "%s" to run linter %s. You may need '. 'to install the binary, or adjust your linter configuration. '. "\nTO INSTALL: %s", $binary, get_class($this), $this->getInstallInstructions())); } } } /** * Get the composed executable command, including the interpreter and binary * but without flags or paths. This can be used to execute `--version` * commands. * * @return string Command to execute the raw linter. * @task exec */ protected function getExecutableCommand() { $this->checkBinaryConfiguration(); $interpreter = null; if ($this->shouldUseInterpreter()) { $interpreter = $this->getInterpreter(); } $binary = $this->getBinary(); if ($interpreter) { $bin = csprintf('%s %s', $interpreter, $binary); } else { $bin = csprintf('%s', $binary); } return $bin; } /** * Get the composed flags for the executable, including both mandatory and * configured flags. * * @return list Composed flags. * @task exec */ protected function getCommandFlags() { $mandatory_flags = $this->getMandatoryFlags(); if (!is_array($mandatory_flags)) { phutil_deprecated( 'String support for flags.', 'You should use list instead.'); $mandatory_flags = (array) $mandatory_flags; } $flags = nonempty($this->flags, $this->getDefaultFlags()); if (!is_array($flags)) { phutil_deprecated( 'String support for flags.', 'You should use list instead.'); $flags = (array) $flags; } return array_merge($mandatory_flags, $flags); } public function getCacheVersion() { $version = $this->getVersion(); if ($version) { return $version.'-'.json_encode($this->getCommandFlags()); } else { // Either we failed to parse the version number or the `getVersion` // function hasn't been implemented. return json_encode($this->getCommandFlags()); } } - public function getVersion() { - return null; - } - protected function buildFutures(array $paths) { $executable = $this->getExecutableCommand(); $bin = csprintf('%C %Ls', $executable, $this->getCommandFlags()); $futures = array(); foreach ($paths as $path) { if ($this->supportsReadDataFromStdin()) { $future = new ExecFuture( '%C %C', $bin, $this->getReadDataFromStdinFilename()); $future->write($this->getEngine()->loadData($path)); } else { // TODO: In commit hook mode, we need to do more handling here. $disk_path = $this->getEngine()->getFilePathOnDisk($path); $future = new ExecFuture('%C %s', $bin, $disk_path); } $futures[$path] = $future; } return $futures; } protected function resolveFuture($path, Future $future) { list($err, $stdout, $stderr) = $future->resolve(); if ($err && !$this->shouldExpectCommandErrors()) { $future->resolvex(); } $messages = $this->parseLinterOutput($path, $err, $stdout, $stderr); if ($messages === false) { if ($err) { $future->resolvex(); } else { throw new Exception( "Linter failed to parse output!\n\n{$stdout}\n\n{$stderr}"); } } foreach ($messages as $message) { $this->addLintMessage($message); } } public function getLinterConfigurationOptions() { $options = array( 'bin' => 'optional string | list', 'flags' => 'optional list', ); if ($this->shouldUseInterpreter()) { $options['interpreter'] = 'optional string | list'; } return $options + parent::getLinterConfigurationOptions(); } public function setLinterConfigurationValue($key, $value) { switch ($key) { case 'interpreter': $working_copy = $this->getEngine()->getWorkingCopy(); $root = $working_copy->getProjectRoot(); foreach ((array)$value as $path) { if (Filesystem::binaryExists($path)) { $this->setInterpreter($path); return; } $path = Filesystem::resolvePath($path, $root); if (Filesystem::binaryExists($path)) { $this->setInterpreter($path); return; } } throw new Exception( pht('None of the configured interpreters can be located.')); case 'bin': $is_script = $this->shouldUseInterpreter(); $working_copy = $this->getEngine()->getWorkingCopy(); $root = $working_copy->getProjectRoot(); foreach ((array)$value as $path) { if (!$is_script && Filesystem::binaryExists($path)) { $this->setBinary($path); return; } $path = Filesystem::resolvePath($path, $root); if ((!$is_script && Filesystem::binaryExists($path)) || ($is_script && Filesystem::pathExists($path))) { $this->setBinary($path); return; } } throw new Exception( pht('None of the configured binaries can be located.')); case 'flags': if (!is_array($value)) { phutil_deprecated( 'String support for flags.', 'You should use list instead.'); $value = (array) $value; } $this->setFlags($value); return; } return parent::setLinterConfigurationValue($key, $value); } /** * Map a configuration lint code to an `arc` lint code. Primarily, this is * intended for validation, but can also be used to normalize case or * otherwise be more permissive in accepted inputs. * * If the code is not recognized, you should throw an exception. * * @param string Code specified in configuration. * @return string Normalized code to use in severity map. */ protected function getLintCodeFromLinterConfigurationKey($code) { return $code; } } diff --git a/src/lint/linter/ArcanistLinter.php b/src/lint/linter/ArcanistLinter.php index fd8700af..caae477d 100644 --- a/src/lint/linter/ArcanistLinter.php +++ b/src/lint/linter/ArcanistLinter.php @@ -1,388 +1,441 @@ getLinterName(), + $this->getLinterConfigurationName(), + get_class($this)); + } + public function getLinterPriority() { return 1.0; } public function setCustomSeverityMap(array $map) { $this->customSeverityMap = $map; return $this; } public function setCustomSeverityRules(array $rules) { $this->customSeverityRules = $rules; return $this; } public function setConfig(array $config) { $this->config = $config; return $this; } protected function getConfig($key, $default = null) { return idx($this->config, $key, $default); } public function getActivePath() { return $this->activePath; } public function getOtherLocation($offset, $path = null) { if ($path === null) { $path = $this->getActivePath(); } list($line, $char) = $this->getEngine()->getLineAndCharFromOffset( $path, $offset); return array( 'path' => $path, 'line' => $line + 1, 'char' => $char, ); } 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 setPaths(array $paths) { $this->paths = $paths; return $this; } /** * Filter out paths which this linter doesn't act on (for example, because * they are binaries and the linter doesn't apply to binaries). */ private function filterPaths($paths) { $engine = $this->getEngine(); $keep = array(); foreach ($paths as $path) { if (!$this->shouldLintDeletedFiles() && !$engine->pathExists($path)) { continue; } if (!$this->shouldLintDirectories() && $engine->isDirectory($path)) { continue; } if (!$this->shouldLintBinaryFiles() && $engine->isBinaryFile($path)) { continue; } $keep[] = $path; } return $keep; } public function getPaths() { return $this->filterPaths(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)) { $this->data[$path] = $this->getEngine()->loadData($path); } return $this->data[$path]; } public function setEngine(ArcanistLintEngine $engine) { $this->engine = $engine; return $this; } protected function getEngine() { return $this->engine; } public function getCacheVersion() { return 0; } 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]; } foreach ($this->customSeverityRules as $rule => $severity) { if (preg_match($rule, $code)) { return $severity; } } return $this->getDefaultMessageSeverity($code); } protected function getDefaultMessageSeverity($code) { return ArcanistLintSeverity::SEVERITY_ERROR; } public function isMessageEnabled($code) { return ($this->getLintMessageSeverity($code) !== ArcanistLintSeverity::SEVERITY_DISABLED); } public function getLintMessageName($code) { $map = $this->getLintNameMap(); if (isset($map[$code])) { return $map[$code]; } return "Unknown lint message!"; } protected function addLintMessage(ArcanistLintMessage $message) { if (!$this->getEngine()->getCommitHookMode()) { $root = $this->getEngine()->getWorkingCopy()->getProjectRoot(); $path = Filesystem::resolvePath($message->getPath(), $root); $message->setPath(Filesystem::readablePath($path, $root)); } $this->messages[] = $message; return $message; } public function getLintMessages() { return $this->messages; } protected function raiseLintAtLine( $line, $char, $code, $desc, $original = null, $replacement = null) { $message = id(new ArcanistLintMessage()) ->setPath($this->getActivePath()) ->setLine($line) ->setChar($char) ->setCode($this->getLintMessageFullCode($code)) ->setSeverity($this->getLintMessageSeverity($code)) ->setName($this->getLintMessageName($code)) ->setDescription($desc) ->setOriginalText($original) ->setReplacementText($replacement); return $this->addLintMessage($message); } 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; } public function canRun() { return true; } public function willLintPaths(array $paths) { return; } abstract public function lintPath($path); abstract public function getLinterName(); + public function getVersion() { + return null; + } + public function didRunLinters() { // This is a hook. } protected function isCodeEnabled($code) { $severity = $this->getLintMessageSeverity($code); return $this->getEngine()->isSeverityEnabled($severity); } public function getLintSeverityMap() { return array(); } public function getLintNameMap() { return array(); } public function getCacheGranularity() { return self::GRANULARITY_FILE; } /** * If this linter is selectable via `.arclint` configuration files, return * a short, human-readable name to identify it. For example, `"jshint"` or * `"pep8"`. * * If you do not implement this method, the linter will not be selectable * through `.arclint` files. */ public function getLinterConfigurationName() { return null; } public function getLinterConfigurationOptions() { return array( 'severity' => 'optional map', 'severity.rules' => 'optional map', ); } public function setLinterConfigurationValue($key, $value) { $sev_map = array( 'error' => ArcanistLintSeverity::SEVERITY_ERROR, 'warning' => ArcanistLintSeverity::SEVERITY_WARNING, 'autofix' => ArcanistLintSeverity::SEVERITY_AUTOFIX, 'advice' => ArcanistLintSeverity::SEVERITY_ADVICE, 'disabled' => ArcanistLintSeverity::SEVERITY_DISABLED, ); switch ($key) { case 'severity': $custom = array(); foreach ($value as $code => $severity) { if (empty($sev_map[$severity])) { $valid = implode(', ', array_keys($sev_map)); throw new Exception( pht( 'Unknown lint severity "%s". Valid severities are: %s.', $severity, $valid)); } $code = $this->getLintCodeFromLinterConfigurationKey($code); $custom[$code] = $severity; } $this->setCustomSeverityMap($custom); return; case 'severity.rules': foreach ($value as $rule => $severity) { if (@preg_match($rule, '') === false) { throw new Exception( pht( 'Severity rule "%s" is not a valid regular expression.', $rule)); } if (empty($sev_map[$severity])) { $valid = implode(', ', array_keys($sev_map)); throw new Exception( pht( 'Unknown lint severity "%s". Valid severities are: %s.', $severity, $valid)); } } $this->setCustomSeverityRules($value); return; } throw new Exception("Incomplete implementation: {$key}!"); } protected function shouldLintBinaryFiles() { return false; } protected function shouldLintDeletedFiles() { return false; } protected function shouldLintDirectories() { return false; } /** * Map a configuration lint code to an `arc` lint code. Primarily, this is * intended for validation, but can also be used to normalize case or * otherwise be more permissive in accepted inputs. * * If the code is not recognized, you should throw an exception. * * @param string Code specified in configuration. * @return string Normalized code to use in severity map. */ protected function getLintCodeFromLinterConfigurationKey($code) { return $code; } } diff --git a/src/lint/linter/ArcanistNoLintLinter.php b/src/lint/linter/ArcanistNoLintLinter.php index 04fac3d3..67ef6922 100644 --- a/src/lint/linter/ArcanistNoLintLinter.php +++ b/src/lint/linter/ArcanistNoLintLinter.php @@ -1,30 +1,38 @@ getData($path); if (preg_match('/@'.'nolint/', $data)) { $this->stopAllLinters(); } } } diff --git a/src/lint/linter/ArcanistPEP8Linter.php b/src/lint/linter/ArcanistPEP8Linter.php index 688966d4..463bb44d 100644 --- a/src/lint/linter/ArcanistPEP8Linter.php +++ b/src/lint/linter/ArcanistPEP8Linter.php @@ -1,130 +1,142 @@ getEngine()->getConfigurationManager(); return $config->getConfigFromAnySource( 'lint.pep8.options', $this->getConfig('options', array())); } public function shouldUseInterpreter() { return ($this->getDefaultBinary() !== 'pep8'); } public function getDefaultInterpreter() { return 'python2.6'; } public function getDefaultBinary() { if (Filesystem::binaryExists('pep8')) { return 'pep8'; } $config = $this->getEngine()->getConfigurationManager(); $old_prefix = $config->getConfigFromAnySource('lint.pep8.prefix'); $old_bin = $config->getConfigFromAnySource('lint.pep8.bin'); if ($old_prefix || $old_bin) { // TODO: Deprecation warning. $old_bin = nonempty($old_bin, 'pep8'); return $old_prefix.'/'.$old_bin; } $arc_root = dirname(phutil_get_library_root('arcanist')); return $arc_root.'/externals/pep8/pep8.py'; } public function getVersion() { list($stdout) = execx('%C --version', $this->getExecutableCommand()); $matches = array(); if (preg_match('/^(?P\d+\.\d+\.\d+)$/', $stdout, $matches)) { return $matches['version']; } else { return false; } } public function getInstallInstructions() { return pht('Install PEP8 using `easy_install pep8`.'); } public function shouldExpectCommandErrors() { return true; } protected function parseLinterOutput($path, $err, $stdout, $stderr) { $lines = phutil_split_lines($stdout, $retain_endings = false); $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]); $message->setSeverity($this->getLintMessageSeverity($matches[4])); $messages[] = $message; } if ($err && !$messages) { return false; } return $messages; } protected function getDefaultMessageSeverity($code) { if (preg_match('/^W/', $code)) { return ArcanistLintSeverity::SEVERITY_WARNING; } else { // TODO: Once severities/.arclint are more usable, restore this to // "ERROR". // return ArcanistLintSeverity::SEVERITY_ERROR; return ArcanistLintSeverity::SEVERITY_WARNING; } } protected function getLintCodeFromLinterConfigurationKey($code) { if (!preg_match('/^(E|W)\d+$/', $code)) { throw new Exception( pht( 'Unrecognized lint message code "%s". Expected a valid PEP8 '. 'lint code like "%s" or "%s".', $code, "E101", "W291")); } return $code; } } diff --git a/src/lint/linter/ArcanistPhpcsLinter.php b/src/lint/linter/ArcanistPhpcsLinter.php index 2b94bb63..92631251 100644 --- a/src/lint/linter/ArcanistPhpcsLinter.php +++ b/src/lint/linter/ArcanistPhpcsLinter.php @@ -1,142 +1,156 @@ getEngine()->getConfigurationManager(); $options = $config->getConfigFromAnySource('lint.phpcs.options', array()); $standard = $config->getConfigFromAnySource('lint.phpcs.standard'); if (!empty($standard)) { if (is_array($options)) { $options[] = '--standard='.$standard; } else { $options .= ' --standard='.$standard; } } return $options; } public function getDefaultBinary() { // TODO: Deprecation warnings. $config = $this->getEngine()->getConfigurationManager(); return $config->getConfigFromAnySource('lint.phpcs.bin', 'phpcs'); } public function getVersion() { list($stdout) = execx('%C --version', $this->getExecutableCommand()); $matches = array(); $regex = '/^PHP_CodeSniffer version (?P\d+\.\d+\.\d+)\b/'; if (preg_match($regex, $stdout, $matches)) { return $matches['version']; } else { return false; } } public function shouldExpectCommandErrors() { return true; } public function supportsReadDataFromStdin() { return true; } protected function parseLinterOutput($path, $err, $stdout, $stderr) { // NOTE: Some version of PHPCS after 1.4.6 stopped printing a valid, empty // XML document to stdout in the case of no errors. If PHPCS exits with // error 0, just ignore output. if (!$err) { return array(); } $report_dom = new DOMDocument(); $ok = @$report_dom->loadXML($stdout); if (!$ok) { return false; } $files = $report_dom->getElementsByTagName('file'); $messages = array(); foreach ($files as $file) { foreach ($file->childNodes as $child) { if (!($child instanceof DOMElement)) { continue; } if ($child->tagName == 'error') { $prefix = 'E'; } else { $prefix = 'W'; } $code = 'PHPCS.'.$prefix.'.'.$child->getAttribute('source'); $message = new ArcanistLintMessage(); $message->setPath($path); $message->setLine($child->getAttribute('line')); $message->setChar($child->getAttribute('column')); $message->setCode($code); $message->setDescription($child->nodeValue); $message->setSeverity($this->getLintMessageSeverity($code)); $messages[] = $message; } } return $messages; } protected function getDefaultMessageSeverity($code) { if (preg_match('/^PHPCS\\.W\\./', $code)) { return ArcanistLintSeverity::SEVERITY_WARNING; } else { return ArcanistLintSeverity::SEVERITY_ERROR; } } protected function getLintCodeFromLinterConfigurationKey($code) { if (!preg_match('/^PHPCS\\.(E|W)\\./', $code)) { throw new Exception( "Invalid severity code '{$code}', should begin with 'PHPCS.'."); } return $code; } } diff --git a/src/lint/linter/ArcanistSpellingLinter.php b/src/lint/linter/ArcanistSpellingLinter.php index 1f940f04..c04553bd 100644 --- a/src/lint/linter/ArcanistSpellingLinter.php +++ b/src/lint/linter/ArcanistSpellingLinter.php @@ -1,151 +1,157 @@ severity = $severity; $this->wholeWordRules = ArcanistSpellingDefaultData::getFullWordRules(); $this->partialWordRules = ArcanistSpellingDefaultData::getPartialWordRules(); } public function getLinterName() { return 'SPELL'; } public function getLinterConfigurationName() { return 'spelling'; } public function addPartialWordRule( $incorrect_word, $correct_word, $severity = self::LINT_SPELLING_IMPORTANT) { $this->partialWordRules[$severity][$incorrect_word] = $correct_word; } public function addWholeWordRule( $incorrect_word, $correct_word, $severity = self::LINT_SPELLING_IMPORTANT) { $this->wholeWordRules[$severity][$incorrect_word] = $correct_word; } public function getLintSeverityMap() { return array( self::LINT_SPELLING_PICKY => ArcanistLintSeverity::SEVERITY_WARNING, self::LINT_SPELLING_IMPORTANT => ArcanistLintSeverity::SEVERITY_ERROR, ); } public function getLintNameMap() { return array( self::LINT_SPELLING_PICKY => pht('Possible Spelling Mistake'), self::LINT_SPELLING_IMPORTANT => pht('Possible Spelling Mistake'), ); } public function lintPath($path) { foreach ($this->partialWordRules as $severity => $wordlist) { if ($severity >= $this->severity) { if (!$this->isCodeEnabled($severity)) { continue; } foreach ($wordlist as $misspell => $correct) { $this->checkPartialWord($path, $misspell, $correct, $severity); } } } foreach ($this->wholeWordRules as $severity => $wordlist) { if ($severity >= $this->severity) { if (!$this->isCodeEnabled($severity)) { continue; } foreach ($wordlist as $misspell => $correct) { $this->checkWholeWord($path, $misspell, $correct, $severity); } } } } protected function checkPartialWord($path, $word, $correct_word, $severity) { $text = $this->getData($path); $pos = 0; while ($pos < strlen($text)) { $next = stripos($text, $word, $pos); if ($next === false) { return; } $original = substr($text, $next, strlen($word)); $replacement = self::fixLetterCase($correct_word, $original); $this->raiseLintAtOffset( $next, $severity, sprintf( "Possible spelling error. You wrote '%s', but did you mean '%s'?", $word, $correct_word), $original, $replacement); $pos = $next + 1; } } protected function checkWholeWord($path, $word, $correct_word, $severity) { $text = $this->getData($path); $matches = array(); $num_matches = preg_match_all( '#\b'.preg_quote($word, '#').'\b#i', $text, $matches, PREG_OFFSET_CAPTURE); if (!$num_matches) { return; } foreach ($matches[0] as $match) { $original = $match[0]; $replacement = self::fixLetterCase($correct_word, $original); $this->raiseLintAtOffset( $match[1], $severity, sprintf( "Possible spelling error. You wrote '%s', but did you mean '%s'?", $word, $correct_word), $original, $replacement); } } public static function fixLetterCase($string, $case) { if ($case == strtolower($case)) { return strtolower($string); } if ($case == strtoupper($case)) { return strtoupper($string); } if ($case == ucwords(strtolower($case))) { return ucwords(strtolower($string)); } return null; } } diff --git a/src/lint/linter/ArcanistTextLinter.php b/src/lint/linter/ArcanistTextLinter.php index 3b6ee659..1599a750 100644 --- a/src/lint/linter/ArcanistTextLinter.php +++ b/src/lint/linter/ArcanistTextLinter.php @@ -1,288 +1,296 @@ 'optional int', ); return $options + parent::getLinterConfigurationOptions(); } public function setMaxLineLength($new_length) { $this->maxLineLength = $new_length; return $this; } public function setLinterConfigurationValue($key, $value) { switch ($key) { case 'text.max-line-length': $this->setMaxLineLength($value); return; } return parent::setLinterConfigurationValue($key, $value); } public function getLinterName() { return 'TXT'; } public function getLinterConfigurationName() { return 'text'; } public function getLintSeverityMap() { return array( self::LINT_LINE_WRAP => ArcanistLintSeverity::SEVERITY_WARNING, self::LINT_TRAILING_WHITESPACE => ArcanistLintSeverity::SEVERITY_AUTOFIX, self::LINT_BOF_WHITESPACE => ArcanistLintSeverity::SEVERITY_AUTOFIX, self::LINT_EOF_WHITESPACE => ArcanistLintSeverity::SEVERITY_AUTOFIX, ); } public function getLintNameMap() { return array( self::LINT_DOS_NEWLINE => pht('DOS Newlines'), self::LINT_TAB_LITERAL => pht('Tab Literal'), self::LINT_LINE_WRAP => pht('Line Too Long'), self::LINT_EOF_NEWLINE => pht('File Does Not End in Newline'), self::LINT_BAD_CHARSET => pht('Bad Charset'), self::LINT_TRAILING_WHITESPACE => pht('Trailing Whitespace'), self::LINT_NO_COMMIT => pht('Explicit %s', '@no'.'commit'), self::LINT_BOF_WHITESPACE => pht('Leading Whitespace at BOF'), self::LINT_EOF_WHITESPACE => pht('Trailing Whitespace at EOF'), ); } public function lintPath($path) { if (!strlen($this->getData($path))) { // If the file is empty, don't bother; particularly, don't require // the user to add a newline. return; } $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); $this->lintBOFWhitespace($path); $this->lintEOFWhitespace($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"); if ($this->isMessageEnabled(self::LINT_DOS_NEWLINE)) { $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; $bad = '[^\x09\x0A\x20-\x7E]'; $preg = preg_match_all( "/{$bad}(.*{$bad})?/", $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); } if ($this->isMessageEnabled(self::LINT_BAD_CHARSET)) { $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. Consider setting up your '. 'editor to automatically remove trailing whitespace, you will save '. 'time.', $string, ''); } } protected function lintBOFWhitespace($path) { $data = $this->getData($path); $matches = null; $preg = preg_match( '/^\s*\n/', $data, $matches, PREG_OFFSET_CAPTURE); if (!$preg) { return; } list($string, $offset) = $matches[0]; $this->raiseLintAtOffset( $offset, self::LINT_BOF_WHITESPACE, 'This file contains leading whitespace at the beginning of the file. ' . 'This is unnecessary and should be avoided when possible.', $string, ''); } protected function lintEOFWhitespace($path) { $data = $this->getData($path); $matches = null; $preg = preg_match( '/(?<=\n)\s+$/', $data, $matches, PREG_OFFSET_CAPTURE); if (!$preg) { return; } list($string, $offset) = $matches[0]; $this->raiseLintAtOffset( $offset, self::LINT_EOF_WHITESPACE, 'This file contains trailing whitespace at the end of the file. This ' . 'is unnecessary and should be avoided when possible.', $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/ArcanistXMLLinter.php b/src/lint/linter/ArcanistXMLLinter.php index 4ce1a1da..59e05feb 100644 --- a/src/lint/linter/ArcanistXMLLinter.php +++ b/src/lint/linter/ArcanistXMLLinter.php @@ -1,68 +1,77 @@ getData($path))) { // XML appears to be valid. return; } foreach (libxml_get_errors() as $error) { $message = new ArcanistLintMessage(); $message->setPath($path); $message->setLine($error->line); $message->setChar($error->column ? $error->column : null); $message->setCode($this->getLintMessageFullCode($error->code)); $message->setName($this->getLintMessageName($error->code)); $message->setDescription(trim($error->message)); switch ($error->level) { case LIBXML_ERR_NONE: $message->setSeverity(ArcanistLintSeverity::SEVERITY_DISABLED); break; case LIBXML_ERR_WARNING: $message->setSeverity(ArcanistLintSeverity::SEVERITY_WARNING); break; case LIBXML_ERR_ERROR: case LIBXML_ERR_FATAL: $message->setSeverity(ArcanistLintSeverity::SEVERITY_ERROR); break; default: $message->setSeverity(ArcanistLintSeverity::SEVERITY_ADVICE); break; } $this->addLintMessage($message); } } } diff --git a/src/workflow/ArcanistBaseWorkflow.php b/src/workflow/ArcanistBaseWorkflow.php index e4fd4e2f..9ebe9d20 100644 --- a/src/workflow/ArcanistBaseWorkflow.php +++ b/src/workflow/ArcanistBaseWorkflow.php @@ -1,1744 +1,1794 @@ finalizeWorkingCopy(); } /** * Return the command used to invoke this workflow from the command like, * e.g. "help" for @{class:ArcanistHelpWorkflow}. * * @return string The command a user types to invoke this workflow. */ abstract public function getWorkflowName(); /** * Return console formatted string with all command synopses. * * @return string 6-space indented list of available command synopses. */ abstract public function getCommandSynopses(); /** * Return console formatted string with command help printed in `arc help`. * * @return string 10-space indented help to use the command. */ abstract public function getCommandHelp(); /* -( Conduit )------------------------------------------------------------ */ /** * Set the URI which the workflow will open a conduit connection to when * @{method:establishConduit} is called. Arcanist makes an effort to set * this by default for all workflows (by reading ##.arcconfig## and/or the * value of ##--conduit-uri##) even if they don't need Conduit, so a workflow * can generally upgrade into a conduit workflow later by just calling * @{method:establishConduit}. * * You generally should not need to call this method unless you are * specifically overriding the default URI. It is normally sufficient to * just invoke @{method:establishConduit}. * * NOTE: You can not call this after a conduit has been established. * * @param string The URI to open a conduit to when @{method:establishConduit} * is called. * @return this * @task conduit */ final public function setConduitURI($conduit_uri) { if ($this->conduit) { throw new Exception( "You can not change the Conduit URI after a conduit is already open."); } $this->conduitURI = $conduit_uri; return $this; } /** * Returns the URI the conduit connection within the workflow uses. * * @return string * @task conduit */ final public function getConduitURI() { return $this->conduitURI; } /** * Open a conduit channel to the server which was previously configured by * calling @{method:setConduitURI}. Arcanist will do this automatically if * the workflow returns ##true## from @{method:requiresConduit}, or you can * later upgrade a workflow and build a conduit by invoking it manually. * * You must establish a conduit before you can make conduit calls. * * NOTE: You must call @{method:setConduitURI} before you can call this * method. * * @return this * @task conduit */ final public function establishConduit() { if ($this->conduit) { return $this; } if (!$this->conduitURI) { throw new Exception( "You must specify a Conduit URI with setConduitURI() before you can ". "establish a conduit."); } $this->conduit = new ConduitClient($this->conduitURI); if ($this->conduitTimeout) { $this->conduit->setTimeout($this->conduitTimeout); } $user = $this->getConfigFromAnySource('http.basicauth.user'); $pass = $this->getConfigFromAnySource('http.basicauth.pass'); if ($user !== null && $pass !== null) { $this->conduit->setBasicAuthCredentials($user, $pass); } return $this; } final public function getConfigFromAnySource($key) { return $this->configurationManager->getConfigFromAnySource($key); } /** * Set credentials which will be used to authenticate against Conduit. These * credentials can then be used to establish an authenticated connection to * conduit by calling @{method:authenticateConduit}. Arcanist sets some * defaults for all workflows regardless of whether or not they return true * from @{method:requireAuthentication}, based on the ##~/.arcrc## and * ##.arcconf## files if they are present. Thus, you can generally upgrade a * workflow which does not require authentication into an authenticated * workflow by later invoking @{method:requireAuthentication}. You should not * normally need to call this method unless you are specifically overriding * the defaults. * * NOTE: You can not call this method after calling * @{method:authenticateConduit}. * * @param dict A credential dictionary, see @{method:authenticateConduit}. * @return this * @task conduit */ final public function setConduitCredentials(array $credentials) { if ($this->isConduitAuthenticated()) { throw new Exception( "You may not set new credentials after authenticating conduit."); } $this->conduitCredentials = $credentials; return $this; } /** * Force arc to identify with a specific Conduit version during the * protocol handshake. This is primarily useful for development (especially * for sending diffs which bump the client Conduit version), since the client * still actually speaks the builtin version of the protocol. * * Controlled by the --conduit-version flag. * * @param int Version the client should pretend to be. * @return this * @task conduit */ public function forceConduitVersion($version) { $this->forcedConduitVersion = $version; return $this; } /** * Get the protocol version the client should identify with. * * @return int Version the client should claim to be. * @task conduit */ public function getConduitVersion() { return nonempty($this->forcedConduitVersion, 6); } /** * Override the default timeout for Conduit. * * Controlled by the --conduit-timeout flag. * * @param float Timeout, in seconds. * @return this * @task conduit */ public function setConduitTimeout($timeout) { $this->conduitTimeout = $timeout; if ($this->conduit) { $this->conduit->setConduitTimeout($timeout); } return $this; } /** * Open and authenticate a conduit connection to a Phabricator server using * provided credentials. Normally, Arcanist does this for you automatically * when you return true from @{method:requiresAuthentication}, but you can * also upgrade an existing workflow to one with an authenticated conduit * by invoking this method manually. * * You must authenticate the conduit before you can make authenticated conduit * calls (almost all calls require authentication). * * This method uses credentials provided via @{method:setConduitCredentials} * to authenticate to the server: * * - ##user## (required) The username to authenticate with. * - ##certificate## (required) The Conduit certificate to use. * - ##description## (optional) Description of the invoking command. * * Successful authentication allows you to call @{method:getUserPHID} and * @{method:getUserName}, as well as use the client you access with * @{method:getConduit} to make authenticated calls. * * NOTE: You must call @{method:setConduitURI} and * @{method:setConduitCredentials} before you invoke this method. * * @return this * @task conduit */ final public function authenticateConduit() { if ($this->isConduitAuthenticated()) { return $this; } $this->establishConduit(); $credentials = $this->conduitCredentials; try { if (!$credentials) { throw new Exception( "Set conduit credentials with setConduitCredentials() before ". "authenticating conduit!"); } if (empty($credentials['user'])) { throw new ConduitClientException('ERR-INVALID-USER', 'Empty user in credentials.'); } if (empty($credentials['certificate'])) { throw new ConduitClientException('ERR-NO-CERTIFICATE', 'Empty certificate in credentials.'); } $description = idx($credentials, 'description', ''); $user = $credentials['user']; $certificate = $credentials['certificate']; $connection = $this->getConduit()->callMethodSynchronous( 'conduit.connect', array( 'client' => 'arc', 'clientVersion' => $this->getConduitVersion(), 'clientDescription' => php_uname('n').':'.$description, 'user' => $user, 'certificate' => $certificate, 'host' => $this->conduitURI, )); } catch (ConduitClientException $ex) { if ($ex->getErrorCode() == 'ERR-NO-CERTIFICATE' || $ex->getErrorCode() == 'ERR-INVALID-USER') { $conduit_uri = $this->conduitURI; $message = "\n". phutil_console_format( "YOU NEED TO __INSTALL A CERTIFICATE__ TO LOGIN TO PHABRICATOR"). "\n\n". phutil_console_format( " To do this, run: **arc install-certificate**"). "\n\n". "The server '{$conduit_uri}' rejected your request:". "\n". $ex->getMessage(); throw new ArcanistUsageException($message); } else if ($ex->getErrorCode() == 'NEW-ARC-VERSION') { // Cleverly disguise this as being AWESOME!!! echo phutil_console_format("**New Version Available!**\n\n"); echo phutil_console_wrap($ex->getMessage()); echo "\n\n"; echo "In most cases, arc can be upgraded automatically.\n"; $ok = phutil_console_confirm( "Upgrade arc now?", $default_no = false); if (!$ok) { throw $ex; } $root = dirname(phutil_get_library_root('arcanist')); chdir($root); $err = phutil_passthru('%s upgrade', $root.'/bin/arc'); if (!$err) { echo "\nTry running your arc command again.\n"; } exit(1); } else { throw $ex; } } $this->userName = $user; $this->userPHID = $connection['userPHID']; $this->conduitAuthenticated = true; return $this; } /** * @return bool True if conduit is authenticated, false otherwise. * @task conduit */ final protected function isConduitAuthenticated() { return (bool)$this->conduitAuthenticated; } /** * Override this to return true if your workflow requires a conduit channel. * Arc will build the channel for you before your workflow executes. This * implies that you only need an unauthenticated channel; if you need * authentication, override @{method:requiresAuthentication}. * * @return bool True if arc should build a conduit channel before running * the workflow. * @task conduit */ public function requiresConduit() { return false; } /** * Override this to return true if your workflow requires an authenticated * conduit channel. This implies that it requires a conduit. Arc will build * and authenticate the channel for you before the workflow executes. * * @return bool True if arc should build an authenticated conduit channel * before running the workflow. * @task conduit */ public function requiresAuthentication() { return false; } /** * Returns the PHID for the user once they've authenticated via Conduit. * * @return phid Authenticated user PHID. * @task conduit */ final public function getUserPHID() { if (!$this->userPHID) { $workflow = get_class($this); throw new Exception( "This workflow ('{$workflow}') requires authentication, override ". "requiresAuthentication() to return true."); } return $this->userPHID; } /** * Deprecated. See @{method:getUserPHID}. * * @deprecated */ final public function getUserGUID() { phutil_deprecated( 'ArcanistBaseWorkflow::getUserGUID', 'This method has been renamed to getUserPHID().'); return $this->getUserPHID(); } /** * Return the username for the user once they've authenticated via Conduit. * * @return string Authenticated username. * @task conduit */ final public function getUserName() { return $this->userName; } /** * Get the established @{class@libphutil:ConduitClient} in order to make * Conduit method calls. Before the client is available it must be connected, * either implicitly by making @{method:requireConduit} or * @{method:requireAuthentication} return true, or explicitly by calling * @{method:establishConduit} or @{method:authenticateConduit}. * * @return @{class@libphutil:ConduitClient} Live conduit client. * @task conduit */ final 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 setArcanistConfiguration( ArcanistConfiguration $arcanist_configuration) { $this->arcanistConfiguration = $arcanist_configuration; return $this; } public function getArcanistConfiguration() { return $this->arcanistConfiguration; } public function setConfigurationManager( ArcanistConfigurationManager $arcanist_configuration_manager) { $this->configurationManager = $arcanist_configuration_manager; return $this; } public function getConfigurationManager() { return $this->configurationManager; } public function requiresWorkingCopy() { return false; } public function desiresWorkingCopy() { return false; } public function requiresRepositoryAPI() { return false; } public function desiresRepositoryAPI() { return false; } public function setCommand($command) { $this->command = $command; return $this; } public function getCommand() { return $this->command; } public function getArguments() { return array(); } public function setWorkingDirectory($working_directory) { $this->workingDirectory = $working_directory; return $this; } public function getWorkingDirectory() { return $this->workingDirectory; } 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); $workflow->setConfigurationManager($this->getConfigurationManager()); if ($this->repositoryAPI) { $workflow->setRepositoryAPI($this->repositoryAPI); } if ($this->userPHID) { $workflow->userPHID = $this->getUserPHID(); $workflow->userName = $this->getUserName(); } if ($this->conduit) { $workflow->conduit = $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) { return idx($this->arguments, $key, $default); } public function getPassedArguments() { return $this->passedArguments; } 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) { $this->passedArguments = $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; } } foreach ($spec as $long => $options) { if (!empty($options['repeat'])) { $dict[$long] = array(); } } $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)) { $corrected = ArcanistConfiguration::correctArgumentSpelling( $arg_key, array_keys($spec)); if (count($corrected) == 1) { PhutilConsole::getConsole()->writeErr( pht( "(Assuming '%s' is the British spelling of '%s'.)", '--'.$arg_key, '--'.head($corrected))."\n"); $arg_key = head($corrected); } else { throw new ArcanistUsageException(pht( "Unknown argument '%s'. Try 'arc help'.", $arg_key)); } } } else if (!strncmp($arg, '-', 1)) { $arg_key = substr($arg, 1); if (empty($short_to_long_map[$arg_key])) { throw new ArcanistUsageException(pht( "Unknown argument '%s'. Try 'arc help'.", $arg_key)); } $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(pht( "Option '%s' requires a parameter.", $arg)); } if (!empty($options['repeat'])) { $dict[$arg_key][] = $args[$ii + 1]; } else { $dict[$arg_key] = $args[$ii + 1]; } $ii++; } } if ($more) { if ($more_key) { $dict[$more_key] = $more; } else { $example = reset($more); throw new ArcanistUsageException(pht( "Unrecognized argument '%s'. Try 'arc help'.", $example)); } } 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() { $working_copy = $this->getConfigurationManager()->getWorkingCopyIdentity(); if (!$working_copy) { $workflow = get_class($this); throw new Exception( "This workflow ('{$workflow}') requires a working copy, override ". "requiresWorkingCopy() to return true."); } return $working_copy; } public function setWorkingCopy( ArcanistWorkingCopyIdentity $working_copy) { $this->workingCopy = $working_copy; 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']); } public function setCommitMode($mode) { $this->commitMode = $mode; return $this; } public function finalizeWorkingCopy() { if ($this->stashed) { $api = $this->getRepositoryAPI(); $api->unstashChanges(); echo pht('Restored stashed changes to the working directory.') . "\n"; } } public function requireCleanWorkingCopy() { $api = $this->getRepositoryAPI(); $must_commit = array(); $working_copy_desc = phutil_console_format( " Working copy: __%s__\n\n", $api->getPath()); $untracked = $api->getUntrackedChanges(); if ($this->shouldRequireCleanUntrackedFiles()) { if (!empty($untracked)) { echo "You have untracked files in this working copy.\n\n". $working_copy_desc. " Untracked files in working copy:\n". " ".implode("\n ", $untracked)."\n\n"; if ($api instanceof ArcanistGitAPI) { echo phutil_console_wrap( "Since you don't have '.gitignore' rules for these files and have ". "not listed them in '.git/info/exclude', you may have forgotten ". "to 'git add' them to your commit.\n"); } else if ($api instanceof ArcanistSubversionAPI) { echo phutil_console_wrap( "Since you don't have 'svn:ignore' rules for these files, you may ". "have forgotten to 'svn add' them.\n"); } else if ($api instanceof ArcanistMercurialAPI) { echo phutil_console_wrap( "Since you don't have '.hgignore' rules for these files, you ". "may have forgotten to 'hg add' them to your commit.\n"); } if ($this->askForAdd($untracked)) { $api->addToCommit($untracked); $must_commit += array_flip($untracked); } else if ($this->commitMode == self::COMMIT_DISABLE) { $prompt = $this->getAskForAddPrompt($untracked); if (phutil_console_confirm($prompt)) { throw new ArcanistUsageException(pht( "Add these files and then run 'arc %s' again.", $this->getWorkflowName())); } } } } // NOTE: this is a subversion-only concept. $incomplete = $api->getIncompleteChanges(); if ($incomplete) { throw new ArcanistUsageException( "You have incompletely checked out directories in this working copy. ". "Fix them before proceeding.\n\n". $working_copy_desc. " Incomplete directories in working copy:\n". " ".implode("\n ", $incomplete)."\n\n". "You can fix these paths by running 'svn update' on them."); } $conflicts = $api->getMergeConflicts(); if ($conflicts) { throw new ArcanistUsageException( "You have merge conflicts in this working copy. Resolve merge ". "conflicts before proceeding.\n\n". $working_copy_desc. " Conflicts in working copy:\n". " ".implode("\n ", $conflicts)."\n"); } $missing = $api->getMissingChanges(); if ($missing) { throw new ArcanistUsageException( pht( "You have missing files in this working copy. Revert or formally ". "remove them (with `svn rm`) before proceeding.\n\n". "%s". " Missing files in working copy:\n%s\n", $working_copy_desc, " ".implode("\n ", $missing))); } $unstaged = $api->getUnstagedChanges(); if ($unstaged) { echo "You have unstaged changes in this working copy.\n\n". $working_copy_desc. " Unstaged changes in working copy:\n". " ".implode("\n ", $unstaged)."\n"; if ($this->askForAdd($unstaged)) { $api->addToCommit($unstaged); $must_commit += array_flip($unstaged); } else { $permit_autostash = $this->getConfigFromAnySource( 'arc.autostash', false); if ($permit_autostash && $api->canStashChanges()) { echo "Stashing uncommitted changes. (You can restore them with ". "`git stash pop`.)\n"; $api->stashChanges(); $this->stashed = true; } else { throw new ArcanistUsageException( "Stage and commit (or revert) them before proceeding."); } } } $uncommitted = $api->getUncommittedChanges(); foreach ($uncommitted as $key => $path) { if (array_key_exists($path, $must_commit)) { unset($uncommitted[$key]); } } if ($uncommitted) { echo "You have uncommitted changes in this working copy.\n\n". $working_copy_desc. " Uncommitted changes in working copy:\n". " ".implode("\n ", $uncommitted)."\n"; if ($this->askForAdd($uncommitted)) { $must_commit += array_flip($uncommitted); } else { throw new ArcanistUncommittedChangesException( "Commit (or revert) them before proceeding."); } } if ($must_commit) { if ($this->getShouldAmend()) { $commit = head($api->getLocalCommitInformation()); $api->amendCommit($commit['message']); } else if ($api->supportsLocalCommits()) { $commit_message = phutil_console_prompt("Enter commit message:"); if ($commit_message == '') { $commit_message = self::AUTO_COMMIT_TITLE; } $api->doCommit($commit_message); } } } private function getShouldAmend() { if ($this->shouldAmend === null) { $this->shouldAmend = $this->calculateShouldAmend(); } return $this->shouldAmend; } private function calculateShouldAmend() { $api = $this->getRepositoryAPI(); if ($this->isHistoryImmutable() || !$api->supportsAmend()) { return false; } $commits = $api->getLocalCommitInformation(); if (!$commits) { return false; } $commit = reset($commits); $message = ArcanistDifferentialCommitMessage::newFromRawCorpus( $commit['message']); if ($message->getGitSVNBaseRevision()) { return false; } if ($api->getAuthor() != $commit['author']) { return false; } if ($message->getRevisionID() && $this->getArgument('create')) { return false; } // TODO: Check commits since tracking branch. If empty then return false. $repository = $this->loadProjectRepository(); if ($repository) { $callsign = $repository['callsign']; $known_commits = $this->getConduit()->callMethodSynchronous( 'diffusion.getcommits', array('commits' => array('r'.$callsign.$commit['commit']))); if (ifilter($known_commits, 'error', $negate = true)) { return false; } } if (!$message->getRevisionID()) { return true; } $in_working_copy = $api->loadWorkingCopyDifferentialRevisions( $this->getConduit(), array( 'authors' => array($this->getUserPHID()), 'status' => 'status-open', )); if ($in_working_copy) { return true; } return false; } private function askForAdd(array $files) { if ($this->commitMode == self::COMMIT_DISABLE) { return false; } if ($this->commitMode == self::COMMIT_ENABLE) { return true; } $prompt = $this->getAskForAddPrompt($files); return phutil_console_confirm($prompt); } private function getAskForAddPrompt(array $files) { if ($this->getShouldAmend()) { $prompt = pht( 'Do you want to amend these files to the commit?', count($files)); } else { $prompt = pht( 'Do you want to add these files to the commit?', count($files)); } return $prompt; } 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); $bundle->setConduit($conduit); // since the conduit method has changes, assume that these fields // could be unset $bundle->setProjectID(idx($diff, 'projectName')); $bundle->setBaseRevision(idx($diff, 'sourceControlBaseRevision')); $bundle->setRevisionID(idx($diff, 'revisionID')); $bundle->setAuthorName(idx($diff, 'authorName')); $bundle->setAuthorEmail(idx($diff, 'authorEmail')); return $bundle; } /** * Return a list of lines changed by the current diff, or ##null## if the * change list is meaningless (for example, because the path is a directory * or binary file). * * @param string Path within the repository. * @param string Change selection mode (see ArcanistDiffHunk). * @return list|null List of changed line numbers, or null to indicate that * the path is not a line-oriented text file. */ protected function getChangedLines($path, $mode) { $repository_api = $this->getRepositoryAPI(); $full_path = $repository_api->getPath($path); if (is_dir($full_path)) { return null; } if (!file_exists($full_path)) { return null; } $change = $this->getChange($path); if ($change->getFileType() !== ArcanistDiffChangeType::FILE_TEXT) { return null; } $lines = $change->getChangedLines($mode); return array_keys($lines); } protected function getChange($path) { $repository_api = $this->getRepositoryAPI(); // TODO: Very gross $is_git = ($repository_api instanceof ArcanistGitAPI); $is_hg = ($repository_api instanceof ArcanistMercurialAPI); $is_svn = ($repository_api instanceof ArcanistSubversionAPI); if ($is_svn) { // NOTE: In SVN, we don't currently support a "get all local changes" // operation, so special case it. if (empty($this->changeCache[$path])) { $diff = $repository_api->getRawDiffText($path); $parser = $this->newDiffParser(); $changes = $parser->parseDiff($diff); if (count($changes) != 1) { throw new Exception("Expected exactly one change."); } $this->changeCache[$path] = reset($changes); } } else if ($is_git || $is_hg) { if (empty($this->changeCache)) { $changes = $repository_api->getAllLocalChanges(); foreach ($changes as $change) { $this->changeCache[$change->getCurrentPath()] = $change; } } } else { throw new Exception("Missing VCS support."); } if (empty($this->changeCache[$path])) { if ($is_git || $is_hg) { // This can legitimately occur under git/hg if you make a change, // "git/hg 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 normalizeRevisionID($revision_id) { return preg_replace('/^D/i', '', $revision_id); } 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; } /** * Write a message to stderr so that '--json' flags or stdout which is meant * to be piped somewhere aren't disrupted. * * @param string Message to write to stderr. * @return void */ protected function writeStatusMessage($msg) { fwrite(STDERR, $msg); } protected function isHistoryImmutable() { $repository_api = $this->getRepositoryAPI(); $config = $this->getConfigFromAnySource('history.immutable'); if ($config !== null) { return $config; } return $repository_api->isHistoryDefaultImmutable(); } /** * Workflows like 'lint' and 'unit' operate on a list of working copy paths. * The user can either specify the paths explicitly ("a.js b.php"), or by * specfifying a revision ("--rev a3f10f1f") to select all paths modified * since that revision, or by omitting both and letting arc choose the * default relative revision. * * This method takes the user's selections and returns the paths that the * workflow should act upon. * * @param list List of explicitly provided paths. * @param string|null Revision name, if provided. * @param mask Mask of ArcanistRepositoryAPI flags to exclude. * Defaults to ArcanistRepositoryAPI::FLAG_UNTRACKED. * @return list List of paths the workflow should act on. */ protected function selectPathsForWorkflow( array $paths, $rev, $omit_mask = null) { if ($omit_mask === null) { $omit_mask = ArcanistRepositoryAPI::FLAG_UNTRACKED; } if ($paths) { $working_copy = $this->getWorkingCopy(); foreach ($paths as $key => $path) { $full_path = Filesystem::resolvePath($path); if (!Filesystem::pathExists($full_path)) { throw new ArcanistUsageException("Path '{$path}' does not exist!"); } $relative_path = Filesystem::readablePath( $full_path, $working_copy->getProjectRoot()); $paths[$key] = $relative_path; } } else { $repository_api = $this->getRepositoryAPI(); if ($rev) { $this->parseBaseCommitArgument(array($rev)); } $paths = $repository_api->getWorkingCopyStatus(); foreach ($paths as $path => $flags) { if ($flags & $omit_mask) { unset($paths[$path]); } } $paths = array_keys($paths); } return array_values($paths); } protected function renderRevisionList(array $revisions) { $list = array(); foreach ($revisions as $revision) { $list[] = ' - D'.$revision['id'].': '.$revision['title']."\n"; } return implode('', $list); } /* -( Scratch Files )------------------------------------------------------ */ /** * Try to read a scratch file, if it exists and is readable. * * @param string Scratch file name. * @return mixed String for file contents, or false for failure. * @task scratch */ protected function readScratchFile($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->readScratchFile($path); } /** * Try to read a scratch JSON file, if it exists and is readable. * * @param string Scratch file name. * @return array Empty array for failure. * @task scratch */ protected function readScratchJSONFile($path) { $file = $this->readScratchFile($path); if (!$file) { return array(); } return json_decode($file, true); } /** * Try to write a scratch file, if there's somewhere to put it and we can * write there. * * @param string Scratch file name to write. * @param string Data to write. * @return bool True on success, false on failure. * @task scratch */ protected function writeScratchFile($path, $data) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->writeScratchFile($path, $data); } /** * Try to write a scratch JSON file, if there's somewhere to put it and we can * write there. * * @param string Scratch file name to write. * @param array Data to write. * @return bool True on success, false on failure. * @task scratch */ protected function writeScratchJSONFile($path, array $data) { return $this->writeScratchFile($path, json_encode($data)); } /** * Try to remove a scratch file. * * @param string Scratch file name to remove. * @return bool True if the file was removed successfully. * @task scratch */ protected function removeScratchFile($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->removeScratchFile($path); } /** * Get a human-readable description of the scratch file location. * * @param string Scratch file name. * @return mixed String, or false on failure. * @task scratch */ protected function getReadableScratchFilePath($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->getReadableScratchFilePath($path); } /** * Get the path to a scratch file, if possible. * * @param string Scratch file name. * @return mixed File path, or false on failure. * @task scratch */ protected function getScratchFilePath($path) { if (!$this->repositoryAPI) { return false; } return $this->getRepositoryAPI()->getScratchFilePath($path); } protected function getRepositoryEncoding() { $default = 'UTF-8'; return nonempty(idx($this->getProjectInfo(), 'encoding'), $default); } protected function getProjectInfo() { if ($this->projectInfo === null) { $project_id = $this->getWorkingCopy()->getProjectID(); if (!$project_id) { $this->projectInfo = array(); } else { try { $this->projectInfo = $this->getConduit()->callMethodSynchronous( 'arcanist.projectinfo', array( 'name' => $project_id, )); } catch (ConduitClientException $ex) { if ($ex->getErrorCode() != 'ERR-BAD-ARCANIST-PROJECT') { throw $ex; } // TODO: Implement a proper query method that doesn't throw on // project not found. We just swallow this because some pathways, // like Git with uncommitted changes in a repository with a new // project ID, may attempt to access project information before // the project is created. See T2153. return array(); } } } return $this->projectInfo; } protected function loadProjectRepository() { $project = $this->getProjectInfo(); if (isset($project['repository'])) { return $project['repository']; } // NOTE: The rest of the code is here for backwards compatibility. $repository_phid = idx($project, 'repositoryPHID'); if (!$repository_phid) { return array(); } $repositories = $this->getConduit()->callMethodSynchronous( 'repository.query', array()); $repositories = ipull($repositories, null, 'phid'); return idx($repositories, $repository_phid, array()); } protected function newInteractiveEditor($text) { $editor = new PhutilInteractiveEditor($text); $preferred = $this->getConfigFromAnySource('editor'); if ($preferred) { $editor->setPreferredEditor($preferred); } return $editor; } protected function newDiffParser() { $parser = new ArcanistDiffParser(); if ($this->repositoryAPI) { $parser->setRepositoryAPI($this->getRepositoryAPI()); } $parser->setWriteDiffOnFailure(true); return $parser; } protected function resolveCall(ConduitFuture $method, $timeout = null) { try { return $method->resolve($timeout); } catch (ConduitClientException $ex) { if ($ex->getErrorCode() == 'ERR-CONDUIT-CALL') { echo phutil_console_wrap( "This feature requires a newer version of Phabricator. Please ". "update it using these instructions: ". "http://www.phabricator.com/docs/phabricator/article/". "Installation_Guide.html#updating-phabricator\n\n"); } throw $ex; } } protected function dispatchEvent($type, array $data) { $data += array( 'workflow' => $this, ); $event = new PhutilEvent($type, $data); PhutilEventEngine::dispatchEvent($event); return $event; } public function parseBaseCommitArgument(array $argv) { if (!count($argv)) { return; } $api = $this->getRepositoryAPI(); if (!$api->supportsCommitRanges()) { throw new ArcanistUsageException( "This version control system does not support commit ranges."); } if (count($argv) > 1) { throw new ArcanistUsageException( "Specify exactly one base commit. The end of the commit range is ". "always the working copy state."); } $api->setBaseCommit(head($argv)); return $this; } protected function getRepositoryVersion() { if (!$this->repositoryVersion) { $api = $this->getRepositoryAPI(); $commit = $api->getSourceControlBaseRevision(); $versions = array('' => $commit); foreach ($api->getChangedFiles($commit) as $path => $mask) { $versions[$path] = (Filesystem::pathExists($path) ? md5_file($path) : ''); } $this->repositoryVersion = md5(json_encode($versions)); } return $this->repositoryVersion; } /* -( Phabricator Repositories )------------------------------------------- */ /** * Get the PHID of the Phabricator repository this working copy corresponds * to. Returns `null` if no repository can be identified. * * @return phid|null Repository PHID, or null if no repository can be * identified. * * @task phabrep */ protected function getRepositoryPHID() { return idx($this->getRepositoryInformation(), 'phid'); } /** * Get the callsign of the Phabricator repository this working copy * corresponds to. Returns `null` if no repository can be identified. * * @return string|null Repository callsign, or null if no repository can be * identified. * * @task phabrep */ protected function getRepositoryCallsign() { return idx($this->getRepositoryInformation(), 'callsign'); } /** * Get the URI of the Phabricator repository this working copy * corresponds to. Returns `null` if no repository can be identified. * * @return string|null Repository URI, or null if no repository can be * identified. * * @task phabrep */ protected function getRepositoryURI() { return idx($this->getRepositoryInformation(), 'uri'); } /** * Get human-readable reasoning explaining how `arc` evaluated which * Phabricator repository corresponds to this working copy. Used by * `arc which` to explain the process to users. * * @return list Human-readable explanation of the repository * association process. * * @task phabrep */ protected function getRepositoryReasons() { $this->getRepositoryInformation(); return $this->repositoryReasons; } /** * @task phabrep */ private function getRepositoryInformation() { if ($this->repositoryInfo === null) { list($info, $reasons) = $this->loadRepositoryInformation(); $this->repositoryInfo = nonempty($info, array()); $this->repositoryReasons = $reasons; } return $this->repositoryInfo; } /** * @task phabrep */ private function loadRepositoryInformation() { list($query, $reasons) = $this->getRepositoryQuery(); if (!$query) { return array(null, $reasons); } try { $results = $this->getConduit()->callMethodSynchronous( 'repository.query', $query); } catch (ConduitClientException $ex) { if ($ex->getErrorCode() == 'ERR-CONDUIT-CALL') { $reasons[] = pht( 'This version of Arcanist is more recent than the version of '. 'Phabricator you are connecting to: the Phabricator install is '. 'out of date and does not have support for identifying '. 'repositories by callsign or URI. Update Phabricator to enable '. 'these features.'); return array(null, $reasons); } throw $ex; } $result = null; if (!$results) { $reasons[] = pht( 'No repositories matched the query. Check that your configuration '. 'is correct, or use "repository.callsign" to select a repository '. 'explicitly.'); } else if (count($results) > 1) { $reasons[] = pht( 'Multiple repostories (%s) matched the query. You can use the '. '"repository.callsign" configuration to select the one you want.', implode(', ', ipull($results, 'callsign'))); } else { $result = head($results); $reasons[] = pht('Found a unique matching repository.'); } return array($result, $reasons); } /** * @task phabrep */ private function getRepositoryQuery() { $reasons = array(); $callsign = $this->getConfigFromAnySource('repository.callsign'); if ($callsign) { $query = array( 'callsigns' => array($callsign), ); $reasons[] = pht( 'Configuration value "repository.callsign" is set to "%s".', $callsign); return array($query, $reasons); } else { $reasons[] = pht( 'Configuration value "repository.callsign" is empty.'); } $project_info = $this->getProjectInfo(); $project_name = $this->getWorkingCopy()->getProjectID(); if ($this->getProjectInfo()) { if (!empty($project_info['repository']['callsign'])) { $callsign = $project_info['repository']['callsign']; $query = array( 'callsigns' => array($callsign), ); $reasons[] = pht( 'Configuration value "project.name" is set to "%s"; this project '. 'is associated with the "%s" repository.', $project_name, $callsign); return array($query, $reasons); } else { $reasons[] = pht( 'Configuration value "project.name" is set to "%s", but this '. 'project is not associated with a repository.', $project_name); } } else if (strlen($project_name)) { $reasons[] = pht( 'Configuration value "project.name" is set to "%s", but that '. 'project does not exist.', $project_name); } else { $reasons[] = pht( 'Configuration value "project.name" is empty.'); } $uuid = $this->getRepositoryAPI()->getRepositoryUUID(); if ($uuid !== null) { $query = array( 'uuids' => array($uuid), ); $reasons[] = pht( 'The UUID for this working copy is "%s".', $uuid); return array($query, $reasons); } else { $reasons[] = pht( 'This repository has no VCS UUID (this is normal for git/hg).'); } $remote_uri = $this->getRepositoryAPI()->getRemoteURI(); if ($remote_uri !== null) { $query = array( 'remoteURIs' => array($remote_uri), ); $reasons[] = pht( 'The remote URI for this working copy is "%s".', $remote_uri); return array($query, $reasons); } else { $reasons[] = pht( 'Unable to determine the remote URI for this repository.'); } return array(null, $reasons); } + /** + * Build a new lint engine for the current working copy. + * + * Optionally, you can pass an explicit engine class name to build an engine + * of a particular class. Normally this is used to implement an `--engine` + * flag from the CLI. + * + * @param string Optional explicit engine class name. + * @return ArcanistLintEngine Constructed engine. + */ + protected function newLintEngine($engine_class = null) { + $working_copy = $this->getWorkingCopy(); + $config = $this->getConfigurationManager(); + + if (!$engine_class) { + $engine_class = $config->getConfigFromAnySource('lint.engine'); + } + + if (!$engine_class) { + if (Filesystem::pathExists($working_copy->getProjectPath('.arclint'))) { + $engine_class = 'ArcanistConfigurationDrivenLintEngine'; + } + } + + if (!$engine_class) { + throw new ArcanistNoEngineException( + pht( + "No lint engine is configured for this project. ". + "Create an '.arclint' file, or configure an advanced engine ". + "with 'lint.engine' in '.arcconfig'.")); + } + + $base_class = 'ArcanistLintEngine'; + if (!class_exists($engine_class) || + !is_subclass_of($engine_class, $base_class)) { + throw new ArcanistUsageException( + pht( + 'Configured lint engine "%s" is not a subclass of "%s", but must '. + 'be.', + $engine_class, + $base_class)); + } + + $engine = newv($engine_class, array()) + ->setWorkingCopy($working_copy) + ->setConfigurationManager($config); + + return $engine; + } + } diff --git a/src/workflow/ArcanistLintWorkflow.php b/src/workflow/ArcanistLintWorkflow.php index de8266c2..df515585 100644 --- a/src/workflow/ArcanistLintWorkflow.php +++ b/src/workflow/ArcanistLintWorkflow.php @@ -1,667 +1,643 @@ shouldAmendChanges = $should_amend; return $this; } public function setShouldAmendWithoutPrompt($should_amend) { $this->shouldAmendWithoutPrompt = $should_amend; return $this; } public function setShouldAmendAutofixesWithoutPrompt($should_amend) { $this->shouldAmendAutofixesWithoutPrompt = $should_amend; return $this; } public function getCommandSynopses() { return phutil_console_format(<< array( 'help' => "Show all lint warnings, not just those on changed lines. When " . "paths are specified, this is the default behavior.", 'conflicts' => array( 'only-changed' => true, ), ), 'only-changed' => array( 'help' => "Show lint warnings just on changed lines. When no paths are " . "specified, this is the default. This differs from only-new " . "in cases where line modifications introduce lint on other " . "unmodified lines.", 'conflicts' => array( 'lintall' => true, ), ), 'rev' => array( 'param' => 'revision', 'help' => "Lint changes since a specific revision.", 'supports' => array( 'git', 'hg', ), 'nosupport' => array( 'svn' => "Lint does not currently support --rev in SVN.", ), ), 'output' => array( 'param' => 'format', 'help' => "With 'summary', show lint warnings in a more compact format. ". "With 'json', show lint warnings in machine-readable JSON format. ". "With 'none', show no lint warnings. ". "With 'compiler', show lint warnings in suitable for your editor." ), 'only-new' => array( 'param' => 'bool', 'supports' => array('git', 'hg'), // TODO: svn 'help' => 'Display only messages not present in the original code.', ), '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, ), ), 'amend-all' => array( 'help' => 'When linting git repositories, amend HEAD with all patches '. 'suggested by lint without prompting.', ), 'amend-autofixes' => array( 'help' => 'When linting git repositories, amend HEAD with autofix '. 'patches suggested by lint without prompting.', ), 'everything' => array( 'help' => 'Lint all files in the project.', 'conflicts' => array( 'cache' => '--everything lints all files', 'rev' => '--everything lints all files' ), ), 'severity' => array( 'param' => 'string', 'help' => "Set minimum message severity. One of: '". implode( "', '", array_keys(ArcanistLintSeverity::getLintSeverities())). "'. Defaults to '".self::DEFAULT_SEVERITY."'.", ), 'cache' => array( 'param' => 'bool', 'help' => "0 to disable cache, 1 to enable. The default value is ". "determined by 'arc.lint.cache' in configuration, which defaults ". "to off. See notes in 'arc.lint.cache'.", ), '*' => 'paths', ); } public function requiresAuthentication() { return (bool)$this->getArgument('only-new'); } public function requiresWorkingCopy() { return true; } public function requiresRepositoryAPI() { return true; } private function getCacheKey() { return implode("\n", array( get_class($this->engine), $this->getArgument('severity', self::DEFAULT_SEVERITY), $this->shouldLintAll, )); } public function run() { $console = PhutilConsole::getConsole(); $working_copy = $this->getWorkingCopy(); $configuration_manager = $this->getConfigurationManager(); - $engine = $this->getArgument('engine'); - if (!$engine) { - $engine = $configuration_manager->getConfigFromAnySource('lint.engine'); - } - - if (!$engine) { - if (Filesystem::pathExists($working_copy->getProjectPath('.arclint'))) { - $engine = 'ArcanistConfigurationDrivenLintEngine'; - } - } - - if (!$engine) { - throw new ArcanistNoEngineException( - "No lint engine configured for this project. Edit '.arcconfig' to ". - "specify a lint engine, or create an '.arclint' file."); - } + $engine = $this->newLintEngine($this->getArgument('engine')); $rev = $this->getArgument('rev'); $paths = $this->getArgument('paths'); $use_cache = $this->getArgument('cache', null); $everything = $this->getArgument('everything'); if ($everything && $paths) { throw new ArcanistUsageException( "You can not specify paths with --everything. The --everything ". "flag lints every file."); } if ($use_cache === null) { $use_cache = (bool)$configuration_manager->getConfigFromAnySource( 'arc.lint.cache', false); } if ($rev && $paths) { throw new ArcanistUsageException("Specify either --rev or paths."); } // NOTE: When the user specifies paths, we imply --lintall and show all // warnings for the paths in question. This is easier to deal with for // us and less confusing for users. $this->shouldLintAll = $paths ? true : false; if ($this->getArgument('lintall')) { $this->shouldLintAll = true; } else if ($this->getArgument('only-changed')) { $this->shouldLintAll = false; } if ($everything) { // Recurse through project from root switch ($this->getRepositoryApi()->getSourceControlSystemName()) { case 'git': $filter = '*/.git'; break; case 'svn': $filter = '*/.svn'; break; case 'hg': $filter = '*/.hg'; break; } $paths = id(new FileFinder($working_copy->getProjectRoot())) ->excludePath($filter) ->find(); $this->shouldLintAll = true; } else { $paths = $this->selectPathsForWorkflow($paths, $rev); } - if (!class_exists($engine) || - !is_subclass_of($engine, 'ArcanistLintEngine')) { - throw new ArcanistUsageException( - "Configured lint engine '{$engine}' is not a subclass of ". - "'ArcanistLintEngine'."); - } - - $engine = newv($engine, array()); $this->engine = $engine; - $engine->setWorkingCopy($working_copy); - $engine->setConfigurationManager($configuration_manager); + $engine->setMinimumSeverity( $this->getArgument('severity', self::DEFAULT_SEVERITY)); $file_hashes = array(); if ($use_cache) { $engine->setRepositoryVersion($this->getRepositoryVersion()); $cache = $this->readScratchJSONFile('lint-cache.json'); $cache = idx($cache, $this->getCacheKey(), array()); $cached = array(); foreach ($paths as $path) { $abs_path = $engine->getFilePathOnDisk($path); if (!Filesystem::pathExists($abs_path)) { continue; } $file_hashes[$abs_path] = md5_file($abs_path); if (!isset($cache[$path])) { continue; } $messages = idx($cache[$path], $file_hashes[$abs_path]); if ($messages !== null) { $cached[$path] = $messages; } } if ($cached) { $console->writeErr( pht("Using lint cache, use '--cache 0' to disable it.")."\n"); } $engine->setCachedResults($cached); } // Propagate information about which lines changed to the lint engine. // This is used so that the lint engine can drop warning messages // concerning lines that weren't in the change. $engine->setPaths($paths); if (!$this->shouldLintAll) { foreach ($paths as $path) { // Note that getChangedLines() returns null to indicate that a file // is binary or a directory (i.e., changed lines are not relevant). $engine->setPathChangedLines( $path, $this->getChangedLines($path, 'new')); } } // Enable possible async linting only for 'arc diff' not 'arc lint' if ($this->getParentWorkflow()) { $engine->setEnableAsyncLint(true); } else { $engine->setEnableAsyncLint(false); } if ($this->getArgument('only-new')) { $conduit = $this->getConduit(); $api = $this->getRepositoryAPI(); if ($rev) { $api->setBaseCommit($rev); } $svn_root = id(new PhutilURI($api->getSourceControlPath()))->getPath(); $all_paths = array(); foreach ($paths as $path) { $path = str_replace(DIRECTORY_SEPARATOR, '/', $path); $full_paths = array($path); $change = $this->getChange($path); $type = $change->getType(); if (ArcanistDiffChangeType::isOldLocationChangeType($type)) { $full_paths = $change->getAwayPaths(); } else if (ArcanistDiffChangeType::isNewLocationChangeType($type)) { continue; } else if (ArcanistDiffChangeType::isDeleteChangeType($type)) { continue; } foreach ($full_paths as $full_path) { $all_paths[$svn_root.'/'.$full_path] = $path; } } $lint_future = $conduit->callMethod('diffusion.getlintmessages', array( 'arcanistProject' => $this->getWorkingCopy()->getProjectID(), 'branch' => '', // TODO: Tracking branch. 'commit' => $api->getBaseCommit(), 'files' => array_keys($all_paths), )); } $failed = null; try { $engine->run(); } catch (Exception $ex) { $failed = $ex; } $results = $engine->getResults(); if ($this->getArgument('only-new')) { $total = 0; foreach ($results as $result) { $total += count($result->getMessages()); } // Don't wait for response with default value of --only-new. $timeout = null; if ($this->getArgument('only-new') === null || !$total) { $timeout = 0; } $raw_messages = $this->resolveCall($lint_future, $timeout); if ($raw_messages && $total) { $old_messages = array(); $line_maps = array(); foreach ($raw_messages as $message) { $path = $all_paths[$message['path']]; $line = $message['line']; $code = $message['code']; if (!isset($line_maps[$path])) { $line_maps[$path] = $this->getChange($path)->buildLineMap(); } $new_lines = idx($line_maps[$path], $line); if (!$new_lines) { // Unmodified lines after last hunk. $last_old = ($line_maps[$path] ? last_key($line_maps[$path]) : 0); $news = array_filter($line_maps[$path]); $last_new = ($news ? last(end($news)) : 0); $new_lines = array($line + $last_new - $last_old); } $error = array($code => array(true)); foreach ($new_lines as $new) { if (isset($old_messages[$path][$new])) { $old_messages[$path][$new][$code][] = true; break; } $old_messages[$path][$new] = &$error; } unset($error); } foreach ($results as $result) { foreach ($result->getMessages() as $message) { $path = str_replace(DIRECTORY_SEPARATOR, '/', $message->getPath()); $line = $message->getLine(); $code = $message->getCode(); if (!empty($old_messages[$path][$line][$code])) { $message->setObsolete(true); array_pop($old_messages[$path][$line][$code]); } } $result->sortAndFilterMessages(); } } } // It'd be nice to just return a single result from the run method above // which contains both the lint messages and the postponed linters. // However, to maintain compatibility with existing lint subclasses, use // a separate method call to grab the postponed linters. $this->postponedLinters = $engine->getPostponedLinters(); 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; } if ($this->getArgument('amend-all')) { $this->shouldAmendChanges = true; $this->shouldAmendWithoutPrompt = true; } if ($this->getArgument('amend-autofixes')) { $prompt_autofix_patches = false; $this->shouldAmendChanges = true; $this->shouldAmendAutofixesWithoutPrompt = true; } else { $prompt_autofix_patches = true; } $repository_api = $this->getRepositoryAPI(); if ($this->shouldAmendChanges) { $this->shouldAmendChanges = $repository_api->supportsAmend() && !$this->isHistoryImmutable(); } $wrote_to_disk = false; switch ($this->getArgument('output')) { case 'json': $renderer = new ArcanistLintJSONRenderer(); $prompt_patches = false; $apply_patches = $this->getArgument('apply-patches'); break; case 'summary': $renderer = new ArcanistLintSummaryRenderer(); break; case 'none': $prompt_patches = false; $apply_patches = $this->getArgument('apply-patches'); $renderer = new ArcanistLintNoneRenderer(); break; case 'compiler': $renderer = new ArcanistLintLikeCompilerRenderer(); $prompt_patches = false; $apply_patches = $this->getArgument('apply-patches'); break; default: $renderer = new ArcanistLintConsoleRenderer(); $renderer->setShowAutofixPatches($prompt_autofix_patches); break; } $all_autofix = true; foreach ($results as $result) { $result_all_autofix = $result->isAllAutofix(); if (!$result->getMessages() && !$result_all_autofix) { continue; } if (!$result_all_autofix) { $all_autofix = false; } $lint_result = $renderer->renderLintResult($result); if ($lint_result) { $console->writeOut('%s', $lint_result); } if ($apply_patches && $result->isPatchable()) { $patcher = ArcanistLintPatcher::newFromArcanistLintResult($result); $old_file = $result->getFilePathOnDisk(); if ($prompt_patches && !($result_all_autofix && !$prompt_autofix_patches)) { if (!Filesystem::pathExists($old_file)) { $old_file = '/dev/null'; } $new_file = new TempFile(); $new = $patcher->getModifiedFileContent(); Filesystem::writeFile($new_file, $new); // TODO: Improve the behavior here, make it more like // difference_render(). list(, $stdout, $stderr) = exec_manual("diff -u %s %s", $old_file, $new_file); $console->writeOut('%s', $stdout); $console->writeErr('%s', $stderr); $prompt = phutil_console_format( "Apply this patch to __%s__?", $result->getPath()); if (!$console->confirm($prompt, $default_no = false)) { continue; } } $patcher->writePatchToDisk(); $wrote_to_disk = true; $file_hashes[$old_file] = md5_file($old_file); } } if ($wrote_to_disk && $this->shouldAmendChanges) { if ($this->shouldAmendWithoutPrompt || ($this->shouldAmendAutofixesWithoutPrompt && $all_autofix)) { $console->writeOut( "** LINT NOTICE ** Automatically amending HEAD ". "with lint patches.\n"); $amend = true; } else { $amend = $console->confirm("Amend HEAD with lint patches?"); } if ($amend) { if ($repository_api instanceof ArcanistGitAPI) { // Add the changes to the index before amending $repository_api->execxLocal('add -u'); } $repository_api->amendCommit(); } else { throw new ArcanistUsageException( "Sort out the lint changes that were applied to the working ". "copy and relint."); } } if ($this->getArgument('output') == 'json') { // NOTE: Required by save_lint.php in Phabricator. return 0; } if ($failed) { if ($failed instanceof ArcanistNoEffectException) { if ($renderer instanceof ArcanistLintNoneRenderer) { return 0; } } throw $failed; } $unresolved = array(); $has_warnings = false; $has_errors = false; foreach ($results as $result) { foreach ($result->getMessages() as $message) { if (!$message->isPatchApplied()) { if ($message->isError()) { $has_errors = true; } else if ($message->isWarning()) { $has_warnings = true; } $unresolved[] = $message; } } } $this->unresolvedMessages = $unresolved; $cache = $this->readScratchJSONFile('lint-cache.json'); $cached = idx($cache, $this->getCacheKey(), array()); if ($cached || $use_cache) { $stopped = $engine->getStoppedPaths(); foreach ($results as $result) { $path = $result->getPath(); if (!$use_cache) { unset($cached[$path]); continue; } $abs_path = $engine->getFilePathOnDisk($path); if (!Filesystem::pathExists($abs_path)) { continue; } $version = $result->getCacheVersion(); $cached_path = array(); if (isset($stopped[$path])) { $cached_path['stopped'] = $stopped[$path]; } $cached_path['repository_version'] = $this->getRepositoryVersion(); foreach ($result->getMessages() as $message) { $granularity = $message->getGranularity(); if ($granularity == ArcanistLinter::GRANULARITY_GLOBAL) { continue; } if (!$message->isPatchApplied()) { $cached_path[] = $message->toDictionary(); } } $hash = idx($file_hashes, $abs_path); if (!$hash) { $hash = md5_file($abs_path); } $cached[$path] = array($hash => array($version => $cached_path)); } $cache[$this->getCacheKey()] = $cached; // TODO: Garbage collection. $this->writeScratchJSONFile('lint-cache.json', $cache); } // Take the most severe lint message severity and use that // as the result code. if ($has_errors) { $result_code = self::RESULT_ERRORS; } else if ($has_warnings) { $result_code = self::RESULT_WARNINGS; } else if (!empty($this->postponedLinters)) { $result_code = self::RESULT_POSTPONED; } else { $result_code = self::RESULT_OKAY; } if (!$this->getParentWorkflow()) { if ($result_code == self::RESULT_OKAY) { $console->writeOut('%s', $renderer->renderOkayResult()); } } return $result_code; } public function getUnresolvedMessages() { return $this->unresolvedMessages; } public function getPostponedLinters() { return $this->postponedLinters; } } diff --git a/src/workflow/ArcanistLintersWorkflow.php b/src/workflow/ArcanistLintersWorkflow.php new file mode 100644 index 00000000..d96488c3 --- /dev/null +++ b/src/workflow/ArcanistLintersWorkflow.php @@ -0,0 +1,176 @@ +setAncestorClass('ArcanistLinter') + ->loadObjects(); + + try { + $built = $this->newLintEngine()->buildLinters(); + } catch (ArcanistNoEngineException $ex) { + $built = $engine->buildLinters(); + } + + // Note that an engine can emit multiple linters of the same class to run + // different rulesets on different groups of files, so these linters do not + // necessarily have unique classes or types. + $groups = array(); + foreach ($built as $linter) { + $groups[get_class($linter)][] = $linter; + } + + $linter_info = array(); + foreach ($linters as $key => $linter) { + $installed = idx($groups, $key, array()); + $exception = null; + + if ($installed) { + $status = 'configured'; + try { + $version = head($installed)->getVersion(); + } catch (Exception $ex) { + $status = 'error'; + $exception = $ex; + } + } else { + $status = 'available'; + $version = null; + } + + $linter_info[$key] = array( + 'short' => $linter->getLinterConfigurationName(), + 'class' => get_class($linter), + 'status' => $status, + 'version' => $version, + 'name' => $linter->getInfoName(), + 'uri' => $linter->getInfoURI(), + 'description' => $linter->getInfoDescription(), + 'exception' => $exception, + ); + } + + $linter_info = isort($linter_info, 'short'); + + $status_map = $this->getStatusMap(); + $pad = ' '; + + $color_map = array( + 'configured' => 'green', + 'available' => 'yellow', + 'error' => 'red', + ); + + foreach ($linter_info as $key => $linter) { + $status = $linter['status']; + $color = $color_map[$status]; + $text = $status_map[$status]; + $print_tail = false; + + $console->writeOut( + "** %s ** **%s** (%s)\n", + $text, + nonempty($linter['short'], '-'), + $linter['name']); + + if ($linter['exception']) { + $console->writeOut( + "\n%s**%s**\n%s\n", + $pad, + get_class($linter['exception']), + phutil_console_wrap( + $linter['exception']->getMessage(), + strlen($pad))); + $print_tail = true; + } + + $version = $linter['version']; + $uri = $linter['uri']; + if ($version || $uri) { + $console->writeOut("\n"); + $print_tail = true; + } + + if ($version) { + $console->writeOut("%s%s **%s**\n", $pad, pht('Version'), $version); + } + + if ($uri) { + $console->writeOut("%s__%s__\n", $pad, $linter['uri']); + } + + $description = $linter['description']; + if ($description) { + $console->writeOut( + "\n%s\n", + phutil_console_wrap($linter['description'], strlen($pad))); + $print_tail = true; + } + + if ($print_tail) { + $console->writeOut("\n"); + } + } + } + + + /** + * Get human-readable linter statuses, padded to fixed width. + * + * @return map Human-readable linter status names. + */ + private function getStatusMap() { + $text_map = array( + 'configured' => pht('CONFIGURED'), + 'available' => pht('AVAILABLE'), + 'error' => pht('ERROR'), + ); + + $sizes = array(); + foreach ($text_map as $key => $string) { + $sizes[$key] = phutil_utf8_console_strlen($string); + } + + $longest = max($sizes); + foreach ($text_map as $key => $string) { + if ($sizes[$key] < $longest) { + $text_map[$key] .= str_repeat(' ', $longest - $sizes[$key]); + } + } + + $text_map['padding'] = str_repeat(' ', $longest); + + return $text_map; + } + +}