diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index a1bd6920..6c4fe32c 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1,519 +1,554 @@ 2, 'class' => array( 'ArcanistAliasFunctionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistAliasFunctionXHPASTLinterRule.php', 'ArcanistAliasWorkflow' => 'workflow/ArcanistAliasWorkflow.php', 'ArcanistAmendWorkflow' => 'workflow/ArcanistAmendWorkflow.php', 'ArcanistAnoidWorkflow' => 'workflow/ArcanistAnoidWorkflow.php', 'ArcanistArrayIndexSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistArrayIndexSpacingXHPASTLinterRule.php', 'ArcanistArraySeparatorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistArraySeparatorXHPASTLinterRule.php', 'ArcanistBackoutWorkflow' => 'workflow/ArcanistBackoutWorkflow.php', 'ArcanistBaseCommitParser' => 'parser/ArcanistBaseCommitParser.php', 'ArcanistBaseCommitParserTestCase' => 'parser/__tests__/ArcanistBaseCommitParserTestCase.php', 'ArcanistBaseXHPASTLinter' => 'lint/linter/ArcanistBaseXHPASTLinter.php', 'ArcanistBinaryExpressionSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistBinaryExpressionSpacingXHPASTLinterRule.php', 'ArcanistBlacklistedFunctionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistBlacklistedFunctionXHPASTLinterRule.php', 'ArcanistBookmarkWorkflow' => 'workflow/ArcanistBookmarkWorkflow.php', 'ArcanistBraceFormattingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistBraceFormattingXHPASTLinterRule.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', 'ArcanistCallTimePassByReferenceXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistCallTimePassByReferenceXHPASTLinterRule.php', 'ArcanistCapabilityNotSupportedException' => 'workflow/exception/ArcanistCapabilityNotSupportedException.php', 'ArcanistCastSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistCastSpacingXHPASTLinterRule.php', 'ArcanistCheckstyleXMLLintRenderer' => 'lint/renderer/ArcanistCheckstyleXMLLintRenderer.php', 'ArcanistChmodLinter' => 'lint/linter/ArcanistChmodLinter.php', 'ArcanistChmodLinterTestCase' => 'lint/linter/__tests__/ArcanistChmodLinterTestCase.php', 'ArcanistClassFilenameMismatchXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistClassFilenameMismatchXHPASTLinterRule.php', 'ArcanistClassNameLiteralXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistClassNameLiteralXHPASTLinterRule.php', 'ArcanistCloseRevisionWorkflow' => 'workflow/ArcanistCloseRevisionWorkflow.php', 'ArcanistCloseWorkflow' => 'workflow/ArcanistCloseWorkflow.php', 'ArcanistClosingCallParenthesesXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistClosingCallParenthesesXHPASTLinterRule.php', 'ArcanistClosingDeclarationParenthesesXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistClosingDeclarationParenthesesXHPASTLinterRule.php', 'ArcanistClosureLinter' => 'lint/linter/ArcanistClosureLinter.php', 'ArcanistClosureLinterTestCase' => 'lint/linter/__tests__/ArcanistClosureLinterTestCase.php', 'ArcanistCoffeeLintLinter' => 'lint/linter/ArcanistCoffeeLintLinter.php', 'ArcanistCoffeeLintLinterTestCase' => 'lint/linter/__tests__/ArcanistCoffeeLintLinterTestCase.php', 'ArcanistCommentRemover' => 'parser/ArcanistCommentRemover.php', 'ArcanistCommentRemoverTestCase' => 'parser/__tests__/ArcanistCommentRemoverTestCase.php', 'ArcanistCommentSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistCommentSpacingXHPASTLinterRule.php', 'ArcanistCommentStyleXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistCommentStyleXHPASTLinterRule.php', 'ArcanistCommitWorkflow' => 'workflow/ArcanistCommitWorkflow.php', 'ArcanistCompilerLintRenderer' => 'lint/renderer/ArcanistCompilerLintRenderer.php', 'ArcanistComprehensiveLintEngine' => 'lint/engine/ArcanistComprehensiveLintEngine.php', 'ArcanistConcatenationOperatorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistConcatenationOperatorXHPASTLinterRule.php', 'ArcanistConfiguration' => 'configuration/ArcanistConfiguration.php', 'ArcanistConfigurationDrivenLintEngine' => 'lint/engine/ArcanistConfigurationDrivenLintEngine.php', 'ArcanistConfigurationManager' => 'configuration/ArcanistConfigurationManager.php', 'ArcanistConsoleLintRenderer' => 'lint/renderer/ArcanistConsoleLintRenderer.php', 'ArcanistConstructorParenthesesXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistConstructorParenthesesXHPASTLinterRule.php', 'ArcanistControlStatementSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistControlStatementSpacingXHPASTLinterRule.php', 'ArcanistCoverWorkflow' => 'workflow/ArcanistCoverWorkflow.php', 'ArcanistCppcheckLinter' => 'lint/linter/ArcanistCppcheckLinter.php', 'ArcanistCppcheckLinterTestCase' => 'lint/linter/__tests__/ArcanistCppcheckLinterTestCase.php', 'ArcanistCpplintLinter' => 'lint/linter/ArcanistCpplintLinter.php', 'ArcanistCpplintLinterTestCase' => 'lint/linter/__tests__/ArcanistCpplintLinterTestCase.php', 'ArcanistDefaultParametersXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistDefaultParametersXHPASTLinterRule.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', 'ArcanistDoubleQuoteXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistDoubleQuoteXHPASTLinterRule.php', 'ArcanistDownloadWorkflow' => 'workflow/ArcanistDownloadWorkflow.php', 'ArcanistDuplicateKeysInArrayXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistDuplicateKeysInArrayXHPASTLinterRule.php', 'ArcanistDuplicateSwitchCaseXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistDuplicateSwitchCaseXHPASTLinterRule.php', 'ArcanistDynamicDefineXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistDynamicDefineXHPASTLinterRule.php', 'ArcanistElseIfUsageXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistElseIfUsageXHPASTLinterRule.php', 'ArcanistEmptyStatementXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistEmptyStatementXHPASTLinterRule.php', 'ArcanistEventType' => 'events/constant/ArcanistEventType.php', 'ArcanistExitExpressionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistExitExpressionXHPASTLinterRule.php', 'ArcanistExportWorkflow' => 'workflow/ArcanistExportWorkflow.php', 'ArcanistExternalLinter' => 'lint/linter/ArcanistExternalLinter.php', 'ArcanistExternalLinterTestCase' => 'lint/linter/__tests__/ArcanistExternalLinterTestCase.php', 'ArcanistExtractUseXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistExtractUseXHPASTLinterRule.php', 'ArcanistFeatureWorkflow' => 'workflow/ArcanistFeatureWorkflow.php', 'ArcanistFileDataRef' => 'upload/ArcanistFileDataRef.php', 'ArcanistFileUploader' => 'upload/ArcanistFileUploader.php', 'ArcanistFilenameLinter' => 'lint/linter/ArcanistFilenameLinter.php', 'ArcanistFilenameLinterTestCase' => 'lint/linter/__tests__/ArcanistFilenameLinterTestCase.php', 'ArcanistFlagWorkflow' => 'workflow/ArcanistFlagWorkflow.php', 'ArcanistFlake8Linter' => 'lint/linter/ArcanistFlake8Linter.php', 'ArcanistFlake8LinterTestCase' => 'lint/linter/__tests__/ArcanistFlake8LinterTestCase.php', 'ArcanistFormattedStringXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistFormattedStringXHPASTLinterRule.php', 'ArcanistFutureLinter' => 'lint/linter/ArcanistFutureLinter.php', 'ArcanistGeneratedLinter' => 'lint/linter/ArcanistGeneratedLinter.php', 'ArcanistGeneratedLinterTestCase' => 'lint/linter/__tests__/ArcanistGeneratedLinterTestCase.php', 'ArcanistGetConfigWorkflow' => 'workflow/ArcanistGetConfigWorkflow.php', 'ArcanistGitAPI' => 'repository/api/ArcanistGitAPI.php', 'ArcanistGoLintLinter' => 'lint/linter/ArcanistGoLintLinter.php', 'ArcanistGoLintLinterTestCase' => 'lint/linter/__tests__/ArcanistGoLintLinterTestCase.php', 'ArcanistGoTestResultParser' => 'unit/parser/ArcanistGoTestResultParser.php', 'ArcanistGoTestResultParserTestCase' => 'unit/parser/__tests__/ArcanistGoTestResultParserTestCase.php', 'ArcanistHLintLinter' => 'lint/linter/ArcanistHLintLinter.php', 'ArcanistHLintLinterTestCase' => 'lint/linter/__tests__/ArcanistHLintLinterTestCase.php', 'ArcanistHelpWorkflow' => 'workflow/ArcanistHelpWorkflow.php', 'ArcanistHgClientChannel' => 'hgdaemon/ArcanistHgClientChannel.php', 'ArcanistHgProxyClient' => 'hgdaemon/ArcanistHgProxyClient.php', 'ArcanistHgProxyServer' => 'hgdaemon/ArcanistHgProxyServer.php', 'ArcanistHgServerChannel' => 'hgdaemon/ArcanistHgServerChannel.php', 'ArcanistImplicitConstructorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistImplicitConstructorXHPASTLinterRule.php', 'ArcanistImplicitFallthroughXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistImplicitFallthroughXHPASTLinterRule.php', 'ArcanistImplicitVisibilityXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistImplicitVisibilityXHPASTLinterRule.php', 'ArcanistInnerFunctionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistInnerFunctionXHPASTLinterRule.php', 'ArcanistInstallCertificateWorkflow' => 'workflow/ArcanistInstallCertificateWorkflow.php', 'ArcanistInstanceOfOperatorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistInstanceOfOperatorXHPASTLinterRule.php', 'ArcanistInvalidDefaultParameterXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistInvalidDefaultParameterXHPASTLinterRule.php', 'ArcanistInvalidModifiersXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistInvalidModifiersXHPASTLinterRule.php', 'ArcanistJSHintLinter' => 'lint/linter/ArcanistJSHintLinter.php', 'ArcanistJSHintLinterTestCase' => 'lint/linter/__tests__/ArcanistJSHintLinterTestCase.php', 'ArcanistJSONLintLinter' => 'lint/linter/ArcanistJSONLintLinter.php', 'ArcanistJSONLintLinterTestCase' => 'lint/linter/__tests__/ArcanistJSONLintLinterTestCase.php', 'ArcanistJSONLintRenderer' => 'lint/renderer/ArcanistJSONLintRenderer.php', 'ArcanistJSONLinter' => 'lint/linter/ArcanistJSONLinter.php', 'ArcanistJSONLinterTestCase' => 'lint/linter/__tests__/ArcanistJSONLinterTestCase.php', 'ArcanistJscsLinter' => 'lint/linter/ArcanistJscsLinter.php', 'ArcanistJscsLinterTestCase' => 'lint/linter/__tests__/ArcanistJscsLinterTestCase.php', 'ArcanistKeywordCasingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistKeywordCasingXHPASTLinterRule.php', 'ArcanistLambdaFuncFunctionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLambdaFuncFunctionXHPASTLinterRule.php', 'ArcanistLandWorkflow' => 'workflow/ArcanistLandWorkflow.php', 'ArcanistLanguageConstructParenthesesXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLanguageConstructParenthesesXHPASTLinterRule.php', 'ArcanistLesscLinter' => 'lint/linter/ArcanistLesscLinter.php', 'ArcanistLesscLinterTestCase' => 'lint/linter/__tests__/ArcanistLesscLinterTestCase.php', 'ArcanistLiberateWorkflow' => 'workflow/ArcanistLiberateWorkflow.php', 'ArcanistLibraryTestCase' => '__tests__/ArcanistLibraryTestCase.php', 'ArcanistLintEngine' => 'lint/engine/ArcanistLintEngine.php', 'ArcanistLintMessage' => 'lint/ArcanistLintMessage.php', 'ArcanistLintPatcher' => 'lint/ArcanistLintPatcher.php', 'ArcanistLintRenderer' => 'lint/renderer/ArcanistLintRenderer.php', 'ArcanistLintResult' => 'lint/ArcanistLintResult.php', 'ArcanistLintSeverity' => 'lint/ArcanistLintSeverity.php', 'ArcanistLintWorkflow' => 'workflow/ArcanistLintWorkflow.php', 'ArcanistLinter' => 'lint/linter/ArcanistLinter.php', 'ArcanistLinterTestCase' => 'lint/linter/__tests__/ArcanistLinterTestCase.php', 'ArcanistLintersWorkflow' => 'workflow/ArcanistLintersWorkflow.php', 'ArcanistListWorkflow' => 'workflow/ArcanistListWorkflow.php', 'ArcanistLogicalOperatorsXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLogicalOperatorsXHPASTLinterRule.php', 'ArcanistLowercaseFunctionsXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistLowercaseFunctionsXHPASTLinterRule.php', 'ArcanistMercurialAPI' => 'repository/api/ArcanistMercurialAPI.php', 'ArcanistMercurialParser' => 'repository/parser/ArcanistMercurialParser.php', 'ArcanistMercurialParserTestCase' => 'repository/parser/__tests__/ArcanistMercurialParserTestCase.php', 'ArcanistMergeConflictLinter' => 'lint/linter/ArcanistMergeConflictLinter.php', 'ArcanistMergeConflictLinterTestCase' => 'lint/linter/__tests__/ArcanistMergeConflictLinterTestCase.php', 'ArcanistMissingLinterException' => 'lint/linter/exception/ArcanistMissingLinterException.php', 'ArcanistModifierOrderingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistModifierOrderingXHPASTLinterRule.php', 'ArcanistNamingConventionsXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistNamingConventionsXHPASTLinterRule.php', 'ArcanistNoEffectException' => 'exception/usage/ArcanistNoEffectException.php', 'ArcanistNoEngineException' => 'exception/usage/ArcanistNoEngineException.php', 'ArcanistNoLintLinter' => 'lint/linter/ArcanistNoLintLinter.php', 'ArcanistNoLintLinterTestCase' => 'lint/linter/__tests__/ArcanistNoLintLinterTestCase.php', 'ArcanistNoParentScopeXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistNoParentScopeXHPASTLinterRule.php', 'ArcanistNoneLintRenderer' => 'lint/renderer/ArcanistNoneLintRenderer.php', 'ArcanistPEP8Linter' => 'lint/linter/ArcanistPEP8Linter.php', 'ArcanistPEP8LinterTestCase' => 'lint/linter/__tests__/ArcanistPEP8LinterTestCase.php', 'ArcanistPHPCloseTagXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPHPCloseTagXHPASTLinterRule.php', 'ArcanistPHPCompatibilityXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPHPCompatibilityXHPASTLinterRule.php', 'ArcanistPHPEchoTagXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPHPEchoTagXHPASTLinterRule.php', 'ArcanistPHPOpenTagXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPHPOpenTagXHPASTLinterRule.php', 'ArcanistPHPShortTagXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPHPShortTagXHPASTLinterRule.php', 'ArcanistParenthesesSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistParenthesesSpacingXHPASTLinterRule.php', 'ArcanistPasteWorkflow' => 'workflow/ArcanistPasteWorkflow.php', 'ArcanistPatchWorkflow' => 'workflow/ArcanistPatchWorkflow.php', 'ArcanistPhpLinter' => 'lint/linter/ArcanistPhpLinter.php', 'ArcanistPhpLinterTestCase' => 'lint/linter/__tests__/ArcanistPhpLinterTestCase.php', 'ArcanistPhpcsLinter' => 'lint/linter/ArcanistPhpcsLinter.php', 'ArcanistPhpcsLinterTestCase' => 'lint/linter/__tests__/ArcanistPhpcsLinterTestCase.php', 'ArcanistPhpunitTestResultParser' => 'unit/parser/ArcanistPhpunitTestResultParser.php', 'ArcanistPhrequentWorkflow' => 'workflow/ArcanistPhrequentWorkflow.php', 'ArcanistPhutilLibraryLinter' => 'lint/linter/ArcanistPhutilLibraryLinter.php', 'ArcanistPhutilXHPASTLinter' => 'lint/linter/ArcanistPhutilXHPASTLinter.php', 'ArcanistPhutilXHPASTLinterTestCase' => 'lint/linter/__tests__/ArcanistPhutilXHPASTLinterTestCase.php', 'ArcanistPlusOperatorOnStringsXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPlusOperatorOnStringsXHPASTLinterRule.php', 'ArcanistPregQuoteMisuseXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistPregQuoteMisuseXHPASTLinterRule.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', 'ArcanistPyLintLinterTestCase' => 'lint/linter/__tests__/ArcanistPyLintLinterTestCase.php', 'ArcanistRepositoryAPI' => 'repository/api/ArcanistRepositoryAPI.php', 'ArcanistRepositoryAPIMiscTestCase' => 'repository/api/__tests__/ArcanistRepositoryAPIMiscTestCase.php', 'ArcanistRepositoryAPIStateTestCase' => 'repository/api/__tests__/ArcanistRepositoryAPIStateTestCase.php', 'ArcanistReusedAsIteratorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistReusedAsIteratorXHPASTLinterRule.php', 'ArcanistReusedIteratorReferenceXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistReusedIteratorReferenceXHPASTLinterRule.php', 'ArcanistReusedIteratorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistReusedIteratorXHPASTLinterRule.php', 'ArcanistRevertWorkflow' => 'workflow/ArcanistRevertWorkflow.php', 'ArcanistRuboCopLinter' => 'lint/linter/ArcanistRuboCopLinter.php', 'ArcanistRuboCopLinterTestCase' => 'lint/linter/__tests__/ArcanistRuboCopLinterTestCase.php', 'ArcanistRubyLinter' => 'lint/linter/ArcanistRubyLinter.php', 'ArcanistRubyLinterTestCase' => 'lint/linter/__tests__/ArcanistRubyLinterTestCase.php', 'ArcanistScriptAndRegexLinter' => 'lint/linter/ArcanistScriptAndRegexLinter.php', 'ArcanistSelfMemberReferenceXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistSelfMemberReferenceXHPASTLinterRule.php', 'ArcanistSemicolonSpacingXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistSemicolonSpacingXHPASTLinterRule.php', 'ArcanistSetConfigWorkflow' => 'workflow/ArcanistSetConfigWorkflow.php', 'ArcanistSettings' => 'configuration/ArcanistSettings.php', 'ArcanistShellCompleteWorkflow' => 'workflow/ArcanistShellCompleteWorkflow.php', 'ArcanistSingleLintEngine' => 'lint/engine/ArcanistSingleLintEngine.php', 'ArcanistSlownessXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistSlownessXHPASTLinterRule.php', 'ArcanistSpellingLinter' => 'lint/linter/ArcanistSpellingLinter.php', 'ArcanistSpellingLinterTestCase' => 'lint/linter/__tests__/ArcanistSpellingLinterTestCase.php', 'ArcanistStartWorkflow' => 'workflow/ArcanistStartWorkflow.php', 'ArcanistStaticThisXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistStaticThisXHPASTLinterRule.php', 'ArcanistStopWorkflow' => 'workflow/ArcanistStopWorkflow.php', 'ArcanistSubversionAPI' => 'repository/api/ArcanistSubversionAPI.php', 'ArcanistSummaryLintRenderer' => 'lint/renderer/ArcanistSummaryLintRenderer.php', 'ArcanistSyntaxErrorXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistSyntaxErrorXHPASTLinterRule.php', 'ArcanistTasksWorkflow' => 'workflow/ArcanistTasksWorkflow.php', 'ArcanistTautologicalExpressionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistTautologicalExpressionXHPASTLinterRule.php', 'ArcanistTestResultParser' => 'unit/parser/ArcanistTestResultParser.php', 'ArcanistTestXHPASTLintSwitchHook' => 'lint/linter/__tests__/ArcanistTestXHPASTLintSwitchHook.php', 'ArcanistTextLinter' => 'lint/linter/ArcanistTextLinter.php', 'ArcanistTextLinterTestCase' => 'lint/linter/__tests__/ArcanistTextLinterTestCase.php', 'ArcanistTimeWorkflow' => 'workflow/ArcanistTimeWorkflow.php', 'ArcanistToStringExceptionXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistToStringExceptionXHPASTLinterRule.php', 'ArcanistTodoCommentXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistTodoCommentXHPASTLinterRule.php', 'ArcanistTodoWorkflow' => 'workflow/ArcanistTodoWorkflow.php', 'ArcanistUSEnglishTranslation' => 'internationalization/ArcanistUSEnglishTranslation.php', 'ArcanistUnableToParseXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUnableToParseXHPASTLinterRule.php', 'ArcanistUndeclaredVariableXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUndeclaredVariableXHPASTLinterRule.php', 'ArcanistUnitConsoleRenderer' => 'unit/renderer/ArcanistUnitConsoleRenderer.php', 'ArcanistUnitRenderer' => 'unit/renderer/ArcanistUnitRenderer.php', 'ArcanistUnitTestEngine' => 'unit/engine/ArcanistUnitTestEngine.php', 'ArcanistUnitTestResult' => 'unit/ArcanistUnitTestResult.php', 'ArcanistUnitTestableLintEngine' => 'lint/engine/ArcanistUnitTestableLintEngine.php', 'ArcanistUnitWorkflow' => 'workflow/ArcanistUnitWorkflow.php', 'ArcanistUnnecessaryFinalModifierXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUnnecessaryFinalModifierXHPASTLinterRule.php', 'ArcanistUnnecessarySemicolonXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUnnecessarySemicolonXHPASTLinterRule.php', 'ArcanistUpgradeWorkflow' => 'workflow/ArcanistUpgradeWorkflow.php', 'ArcanistUploadWorkflow' => 'workflow/ArcanistUploadWorkflow.php', 'ArcanistUsageException' => 'exception/ArcanistUsageException.php', 'ArcanistUselessOverridingMethodXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistUselessOverridingMethodXHPASTLinterRule.php', 'ArcanistUserAbortException' => 'exception/usage/ArcanistUserAbortException.php', 'ArcanistVariableVariableXHPASTLinterRule' => 'lint/linter/xhpast/rules/ArcanistVariableVariableXHPASTLinterRule.php', 'ArcanistVersionWorkflow' => 'workflow/ArcanistVersionWorkflow.php', 'ArcanistWhichWorkflow' => 'workflow/ArcanistWhichWorkflow.php', 'ArcanistWorkflow' => 'workflow/ArcanistWorkflow.php', 'ArcanistWorkingCopyIdentity' => 'workingcopyidentity/ArcanistWorkingCopyIdentity.php', 'ArcanistXHPASTLintNamingHook' => 'lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php', 'ArcanistXHPASTLintNamingHookTestCase' => 'lint/linter/xhpast/__tests__/ArcanistXHPASTLintNamingHookTestCase.php', 'ArcanistXHPASTLintSwitchHook' => 'lint/linter/xhpast/ArcanistXHPASTLintSwitchHook.php', 'ArcanistXHPASTLinter' => 'lint/linter/ArcanistXHPASTLinter.php', 'ArcanistXHPASTLinterRule' => 'lint/linter/xhpast/ArcanistXHPASTLinterRule.php', 'ArcanistXHPASTLinterTestCase' => 'lint/linter/__tests__/ArcanistXHPASTLinterTestCase.php', 'ArcanistXMLLinter' => 'lint/linter/ArcanistXMLLinter.php', 'ArcanistXMLLinterTestCase' => 'lint/linter/__tests__/ArcanistXMLLinterTestCase.php', 'ArcanistXUnitTestResultParser' => 'unit/parser/ArcanistXUnitTestResultParser.php', 'CSharpToolsTestEngine' => 'unit/engine/CSharpToolsTestEngine.php', 'NoseTestEngine' => 'unit/engine/NoseTestEngine.php', 'PhpunitTestEngine' => 'unit/engine/PhpunitTestEngine.php', 'PhpunitTestEngineTestCase' => 'unit/engine/__tests__/PhpunitTestEngineTestCase.php', 'PhutilTestCase' => 'unit/engine/phutil/PhutilTestCase.php', 'PhutilTestCaseTestCase' => 'unit/engine/phutil/testcase/PhutilTestCaseTestCase.php', 'PhutilTestSkippedException' => 'unit/engine/phutil/testcase/PhutilTestSkippedException.php', 'PhutilTestTerminatedException' => 'unit/engine/phutil/testcase/PhutilTestTerminatedException.php', 'PhutilUnitTestEngine' => 'unit/engine/PhutilUnitTestEngine.php', 'PhutilUnitTestEngineTestCase' => 'unit/engine/__tests__/PhutilUnitTestEngineTestCase.php', 'PytestTestEngine' => 'unit/engine/PytestTestEngine.php', 'XUnitTestEngine' => 'unit/engine/XUnitTestEngine.php', 'XUnitTestResultParserTestCase' => 'unit/parser/__tests__/XUnitTestResultParserTestCase.php', ), 'function' => array(), 'xmap' => array( 'ArcanistAliasFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistAliasWorkflow' => 'ArcanistWorkflow', 'ArcanistAmendWorkflow' => 'ArcanistWorkflow', 'ArcanistAnoidWorkflow' => 'ArcanistWorkflow', 'ArcanistArrayIndexSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistArraySeparatorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistBackoutWorkflow' => 'ArcanistWorkflow', + 'ArcanistBaseCommitParser' => 'Phobject', 'ArcanistBaseCommitParserTestCase' => 'PhutilTestCase', 'ArcanistBaseXHPASTLinter' => 'ArcanistFutureLinter', 'ArcanistBinaryExpressionSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistBlacklistedFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistBookmarkWorkflow' => 'ArcanistFeatureWorkflow', 'ArcanistBraceFormattingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistBranchWorkflow' => 'ArcanistFeatureWorkflow', 'ArcanistBritishTestCase' => 'PhutilTestCase', 'ArcanistBrowseWorkflow' => 'ArcanistWorkflow', + 'ArcanistBundle' => 'Phobject', 'ArcanistBundleTestCase' => 'PhutilTestCase', 'ArcanistCSSLintLinter' => 'ArcanistExternalLinter', 'ArcanistCSSLintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistCSharpLinter' => 'ArcanistLinter', 'ArcanistCallConduitWorkflow' => 'ArcanistWorkflow', 'ArcanistCallTimePassByReferenceXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistCapabilityNotSupportedException' => 'Exception', 'ArcanistCastSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistCheckstyleXMLLintRenderer' => 'ArcanistLintRenderer', 'ArcanistChmodLinter' => 'ArcanistLinter', 'ArcanistChmodLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistClassFilenameMismatchXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistClassNameLiteralXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistCloseRevisionWorkflow' => 'ArcanistWorkflow', 'ArcanistCloseWorkflow' => 'ArcanistWorkflow', 'ArcanistClosingCallParenthesesXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistClosingDeclarationParenthesesXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistClosureLinter' => 'ArcanistExternalLinter', 'ArcanistClosureLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistCoffeeLintLinter' => 'ArcanistExternalLinter', 'ArcanistCoffeeLintLinterTestCase' => 'ArcanistExternalLinterTestCase', + 'ArcanistCommentRemover' => 'Phobject', 'ArcanistCommentRemoverTestCase' => 'PhutilTestCase', 'ArcanistCommentSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistCommentStyleXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistCommitWorkflow' => 'ArcanistWorkflow', 'ArcanistCompilerLintRenderer' => 'ArcanistLintRenderer', 'ArcanistComprehensiveLintEngine' => 'ArcanistLintEngine', 'ArcanistConcatenationOperatorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', + 'ArcanistConfiguration' => 'Phobject', 'ArcanistConfigurationDrivenLintEngine' => 'ArcanistLintEngine', + 'ArcanistConfigurationManager' => 'Phobject', 'ArcanistConsoleLintRenderer' => 'ArcanistLintRenderer', 'ArcanistConstructorParenthesesXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistControlStatementSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistCoverWorkflow' => 'ArcanistWorkflow', 'ArcanistCppcheckLinter' => 'ArcanistExternalLinter', 'ArcanistCppcheckLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistCpplintLinter' => 'ArcanistExternalLinter', 'ArcanistCpplintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistDefaultParametersXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', + 'ArcanistDiffChange' => 'Phobject', + 'ArcanistDiffChangeType' => 'Phobject', + 'ArcanistDiffHunk' => 'Phobject', + 'ArcanistDiffParser' => 'Phobject', 'ArcanistDiffParserTestCase' => 'PhutilTestCase', + 'ArcanistDiffUtils' => 'Phobject', 'ArcanistDiffUtilsTestCase' => 'PhutilTestCase', 'ArcanistDiffWorkflow' => 'ArcanistWorkflow', + 'ArcanistDifferentialCommitMessage' => 'Phobject', 'ArcanistDifferentialCommitMessageParserException' => 'Exception', 'ArcanistDifferentialDependencyGraph' => 'AbstractDirectedGraph', + 'ArcanistDifferentialRevisionHash' => 'Phobject', + 'ArcanistDifferentialRevisionStatus' => 'Phobject', 'ArcanistDoubleQuoteXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistDownloadWorkflow' => 'ArcanistWorkflow', 'ArcanistDuplicateKeysInArrayXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistDuplicateSwitchCaseXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistDynamicDefineXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistElseIfUsageXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistEmptyStatementXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistEventType' => 'PhutilEventType', 'ArcanistExitExpressionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistExportWorkflow' => 'ArcanistWorkflow', 'ArcanistExternalLinter' => 'ArcanistFutureLinter', 'ArcanistExternalLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistExtractUseXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistFeatureWorkflow' => 'ArcanistWorkflow', 'ArcanistFileDataRef' => 'Phobject', 'ArcanistFileUploader' => 'Phobject', 'ArcanistFilenameLinter' => 'ArcanistLinter', 'ArcanistFilenameLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistFlagWorkflow' => 'ArcanistWorkflow', 'ArcanistFlake8Linter' => 'ArcanistExternalLinter', 'ArcanistFlake8LinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistFormattedStringXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistFutureLinter' => 'ArcanistLinter', 'ArcanistGeneratedLinter' => 'ArcanistLinter', 'ArcanistGeneratedLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistGetConfigWorkflow' => 'ArcanistWorkflow', 'ArcanistGitAPI' => 'ArcanistRepositoryAPI', 'ArcanistGoLintLinter' => 'ArcanistExternalLinter', 'ArcanistGoLintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistGoTestResultParser' => 'ArcanistTestResultParser', 'ArcanistGoTestResultParserTestCase' => 'PhutilTestCase', 'ArcanistHLintLinter' => 'ArcanistExternalLinter', 'ArcanistHLintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistHelpWorkflow' => 'ArcanistWorkflow', 'ArcanistHgClientChannel' => 'PhutilProtocolChannel', + 'ArcanistHgProxyClient' => 'Phobject', + 'ArcanistHgProxyServer' => 'Phobject', 'ArcanistHgServerChannel' => 'PhutilProtocolChannel', 'ArcanistImplicitConstructorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistImplicitFallthroughXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistImplicitVisibilityXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistInnerFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistInstallCertificateWorkflow' => 'ArcanistWorkflow', 'ArcanistInstanceOfOperatorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistInvalidDefaultParameterXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistInvalidModifiersXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistJSHintLinter' => 'ArcanistExternalLinter', 'ArcanistJSHintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistJSONLintLinter' => 'ArcanistExternalLinter', 'ArcanistJSONLintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistJSONLintRenderer' => 'ArcanistLintRenderer', 'ArcanistJSONLinter' => 'ArcanistLinter', 'ArcanistJSONLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistJscsLinter' => 'ArcanistExternalLinter', 'ArcanistJscsLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistKeywordCasingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLambdaFuncFunctionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLandWorkflow' => 'ArcanistWorkflow', 'ArcanistLanguageConstructParenthesesXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLesscLinter' => 'ArcanistExternalLinter', 'ArcanistLesscLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistLiberateWorkflow' => 'ArcanistWorkflow', 'ArcanistLibraryTestCase' => 'PhutilLibraryTestCase', + 'ArcanistLintEngine' => 'Phobject', + 'ArcanistLintMessage' => 'Phobject', + 'ArcanistLintPatcher' => 'Phobject', + 'ArcanistLintRenderer' => 'Phobject', + 'ArcanistLintResult' => 'Phobject', + 'ArcanistLintSeverity' => 'Phobject', 'ArcanistLintWorkflow' => 'ArcanistWorkflow', + 'ArcanistLinter' => 'Phobject', 'ArcanistLinterTestCase' => 'PhutilTestCase', 'ArcanistLintersWorkflow' => 'ArcanistWorkflow', 'ArcanistListWorkflow' => 'ArcanistWorkflow', 'ArcanistLogicalOperatorsXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistLowercaseFunctionsXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistMercurialAPI' => 'ArcanistRepositoryAPI', + 'ArcanistMercurialParser' => 'Phobject', 'ArcanistMercurialParserTestCase' => 'PhutilTestCase', 'ArcanistMergeConflictLinter' => 'ArcanistLinter', 'ArcanistMergeConflictLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistMissingLinterException' => 'Exception', 'ArcanistModifierOrderingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistNamingConventionsXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistNoEffectException' => 'ArcanistUsageException', 'ArcanistNoEngineException' => 'ArcanistUsageException', 'ArcanistNoLintLinter' => 'ArcanistLinter', 'ArcanistNoLintLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistNoParentScopeXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistNoneLintRenderer' => 'ArcanistLintRenderer', 'ArcanistPEP8Linter' => 'ArcanistExternalLinter', 'ArcanistPEP8LinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistPHPCloseTagXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPHPCompatibilityXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPHPEchoTagXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPHPOpenTagXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPHPShortTagXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistParenthesesSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPasteWorkflow' => 'ArcanistWorkflow', 'ArcanistPatchWorkflow' => 'ArcanistWorkflow', 'ArcanistPhpLinter' => 'ArcanistExternalLinter', 'ArcanistPhpLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistPhpcsLinter' => 'ArcanistExternalLinter', 'ArcanistPhpcsLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistPhpunitTestResultParser' => 'ArcanistTestResultParser', 'ArcanistPhrequentWorkflow' => 'ArcanistWorkflow', 'ArcanistPhutilLibraryLinter' => 'ArcanistLinter', 'ArcanistPhutilXHPASTLinter' => 'ArcanistBaseXHPASTLinter', 'ArcanistPhutilXHPASTLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistPlusOperatorOnStringsXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPregQuoteMisuseXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistPuppetLintLinter' => 'ArcanistExternalLinter', 'ArcanistPuppetLintLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistPyFlakesLinter' => 'ArcanistExternalLinter', 'ArcanistPyFlakesLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistPyLintLinter' => 'ArcanistExternalLinter', 'ArcanistPyLintLinterTestCase' => 'ArcanistExternalLinterTestCase', + 'ArcanistRepositoryAPI' => 'Phobject', 'ArcanistRepositoryAPIMiscTestCase' => 'PhutilTestCase', 'ArcanistRepositoryAPIStateTestCase' => 'PhutilTestCase', 'ArcanistReusedAsIteratorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistReusedIteratorReferenceXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistReusedIteratorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistRevertWorkflow' => 'ArcanistWorkflow', 'ArcanistRuboCopLinter' => 'ArcanistExternalLinter', 'ArcanistRuboCopLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistRubyLinter' => 'ArcanistExternalLinter', 'ArcanistRubyLinterTestCase' => 'ArcanistExternalLinterTestCase', 'ArcanistScriptAndRegexLinter' => 'ArcanistLinter', 'ArcanistSelfMemberReferenceXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistSemicolonSpacingXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistSetConfigWorkflow' => 'ArcanistWorkflow', + 'ArcanistSettings' => 'Phobject', 'ArcanistShellCompleteWorkflow' => 'ArcanistWorkflow', 'ArcanistSingleLintEngine' => 'ArcanistLintEngine', 'ArcanistSlownessXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistSpellingLinter' => 'ArcanistLinter', 'ArcanistSpellingLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistStartWorkflow' => 'ArcanistPhrequentWorkflow', 'ArcanistStaticThisXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistStopWorkflow' => 'ArcanistPhrequentWorkflow', 'ArcanistSubversionAPI' => 'ArcanistRepositoryAPI', 'ArcanistSummaryLintRenderer' => 'ArcanistLintRenderer', 'ArcanistSyntaxErrorXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistTasksWorkflow' => 'ArcanistWorkflow', 'ArcanistTautologicalExpressionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', + 'ArcanistTestResultParser' => 'Phobject', 'ArcanistTestXHPASTLintSwitchHook' => 'ArcanistXHPASTLintSwitchHook', 'ArcanistTextLinter' => 'ArcanistLinter', 'ArcanistTextLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistTimeWorkflow' => 'ArcanistPhrequentWorkflow', 'ArcanistToStringExceptionXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistTodoCommentXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistTodoWorkflow' => 'ArcanistWorkflow', 'ArcanistUSEnglishTranslation' => 'PhutilTranslation', 'ArcanistUnableToParseXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUndeclaredVariableXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUnitConsoleRenderer' => 'ArcanistUnitRenderer', + 'ArcanistUnitRenderer' => 'Phobject', + 'ArcanistUnitTestEngine' => 'Phobject', + 'ArcanistUnitTestResult' => 'Phobject', 'ArcanistUnitTestableLintEngine' => 'ArcanistLintEngine', 'ArcanistUnitWorkflow' => 'ArcanistWorkflow', 'ArcanistUnnecessaryFinalModifierXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUnnecessarySemicolonXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUpgradeWorkflow' => 'ArcanistWorkflow', 'ArcanistUploadWorkflow' => 'ArcanistWorkflow', 'ArcanistUsageException' => 'Exception', 'ArcanistUselessOverridingMethodXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistUserAbortException' => 'ArcanistUsageException', 'ArcanistVariableVariableXHPASTLinterRule' => 'ArcanistXHPASTLinterRule', 'ArcanistVersionWorkflow' => 'ArcanistWorkflow', 'ArcanistWhichWorkflow' => 'ArcanistWorkflow', 'ArcanistWorkflow' => 'Phobject', + 'ArcanistWorkingCopyIdentity' => 'Phobject', + 'ArcanistXHPASTLintNamingHook' => 'Phobject', 'ArcanistXHPASTLintNamingHookTestCase' => 'PhutilTestCase', + 'ArcanistXHPASTLintSwitchHook' => 'Phobject', 'ArcanistXHPASTLinter' => 'ArcanistBaseXHPASTLinter', + 'ArcanistXHPASTLinterRule' => 'Phobject', 'ArcanistXHPASTLinterTestCase' => 'ArcanistLinterTestCase', 'ArcanistXMLLinter' => 'ArcanistLinter', 'ArcanistXMLLinterTestCase' => 'ArcanistLinterTestCase', + 'ArcanistXUnitTestResultParser' => 'Phobject', 'CSharpToolsTestEngine' => 'XUnitTestEngine', 'NoseTestEngine' => 'ArcanistUnitTestEngine', 'PhpunitTestEngine' => 'ArcanistUnitTestEngine', 'PhpunitTestEngineTestCase' => 'PhutilTestCase', + 'PhutilTestCase' => 'Phobject', 'PhutilTestCaseTestCase' => 'PhutilTestCase', 'PhutilTestSkippedException' => 'Exception', 'PhutilTestTerminatedException' => 'Exception', 'PhutilUnitTestEngine' => 'ArcanistUnitTestEngine', 'PhutilUnitTestEngineTestCase' => 'PhutilTestCase', 'PytestTestEngine' => 'ArcanistUnitTestEngine', 'XUnitTestEngine' => 'ArcanistUnitTestEngine', 'XUnitTestResultParserTestCase' => 'PhutilTestCase', ), )); diff --git a/src/configuration/ArcanistConfiguration.php b/src/configuration/ArcanistConfiguration.php index 5755cc5e..71152587 100644 --- a/src/configuration/ArcanistConfiguration.php +++ b/src/configuration/ArcanistConfiguration.php @@ -1,272 +1,272 @@ buildAllWorkflows(), $command); } public function buildAllWorkflows() { $workflows_by_name = array(); $workflows_by_class_name = id(new PhutilSymbolLoader()) ->setAncestorClass('ArcanistWorkflow') ->loadObjects(); foreach ($workflows_by_class_name as $class => $workflow) { $name = $workflow->getWorkflowName(); if (isset($workflows_by_name[$name])) { $other = get_class($workflows_by_name[$name]); throw new Exception( pht( 'Workflows %s and %s both implement workflows named %s.', $class, $other, $name)); } $workflows_by_name[$name] = $workflow; } return $workflows_by_name; } final public function isValidWorkflow($workflow) { return (bool)$this->buildWorkflow($workflow); } public function willRunWorkflow($command, ArcanistWorkflow $workflow) { // This is a hook. } public function didRunWorkflow($command, ArcanistWorkflow $workflow, $err) { // This is a hook. } public function didAbortWorkflow($command, $workflow, Exception $ex) { // This is a hook. } public function getCustomArgumentsForCommand($command) { return array(); } final public function selectWorkflow( &$command, array &$args, ArcanistConfigurationManager $configuration_manager, PhutilConsole $console) { // First, try to build a workflow with the exact name provided. We always // pick an exact match, and do not allow aliases to override it. $workflow = $this->buildWorkflow($command); if ($workflow) { return $workflow; } // If the user has an alias, like 'arc alias dhelp diff help', look it up // and substitute it. We do this only after trying to resolve the workflow // normally to prevent you from doing silly things like aliasing 'alias' // to something else. $aliases = ArcanistAliasWorkflow::getAliases($configuration_manager); list($new_command, $args) = ArcanistAliasWorkflow::resolveAliases( $command, $this, $args, $configuration_manager); $full_alias = idx($aliases, $command, array()); $full_alias = implode(' ', $full_alias); // Run shell command aliases. if (ArcanistAliasWorkflow::isShellCommandAlias($new_command)) { $shell_cmd = substr($full_alias, 1); $console->writeLog( "[%s: 'arc %s' -> $ %s]", pht('alias'), $command, $shell_cmd); if ($args) { $err = phutil_passthru('%C %Ls', $shell_cmd, $args); } else { $err = phutil_passthru('%C', $shell_cmd); } exit($err); } // Run arc command aliases. if ($new_command) { $workflow = $this->buildWorkflow($new_command); if ($workflow) { $console->writeLog( "[%s: 'arc %s' -> 'arc %s']\n", pht('alias'), $command, $full_alias); $command = $new_command; return $workflow; } } $all = array_keys($this->buildAllWorkflows()); // We haven't found a real command or an alias, so try to locate a command // by unique prefix. $prefixes = $this->expandCommandPrefix($command, $all); if (count($prefixes) == 1) { $command = head($prefixes); return $this->buildWorkflow($command); } else if (count($prefixes) > 1) { $this->raiseUnknownCommand($command, $prefixes); } // We haven't found a real command, alias, or unique prefix. Try similar // spellings. $corrected = self::correctCommandSpelling($command, $all, 2); if (count($corrected) == 1) { $console->writeErr( pht( "(Assuming '%s' is the British spelling of '%s'.)", $command, head($corrected))."\n"); $command = head($corrected); return $this->buildWorkflow($command); } else if (count($corrected) > 1) { $this->raiseUnknownCommand($command, $corrected); } $this->raiseUnknownCommand($command); } private function raiseUnknownCommand($command, array $maybe = array()) { $message = pht("Unknown command '%s'. Try '%s'.", $command, 'arc help'); if ($maybe) { $message .= "\n\n".pht('Did you mean:')."\n"; sort($maybe); foreach ($maybe as $other) { $message .= " ".$other."\n"; } } throw new ArcanistUsageException($message); } private function expandCommandPrefix($command, array $options) { $is_prefix = array(); foreach ($options as $option) { if (strncmp($option, $command, strlen($command)) == 0) { $is_prefix[$option] = true; } } return array_keys($is_prefix); } public static function correctCommandSpelling( $command, array $options, $max_distance) { // Adjust to the scaled edit costs we use below, so "2" roughly means // "2 edits". $max_distance = $max_distance * 3; // These costs are somewhat made up, but the theory is that it is far more // likely you will mis-strike a key ("lans" for "land") or press two keys // out of order ("alnd" for "land") than omit keys or press extra keys. $matrix = id(new PhutilEditDistanceMatrix()) ->setInsertCost(4) ->setDeleteCost(4) ->setReplaceCost(3) ->setTransposeCost(2); return self::correctSpelling($command, $options, $matrix, $max_distance); } public static function correctArgumentSpelling($command, array $options) { $max_distance = 1; // We are stricter with arguments - we allow only one inserted or deleted // character. It is mainly to handle cases like --no-lint versus --nolint // or --reviewer versus --reviewers. $matrix = id(new PhutilEditDistanceMatrix()) ->setInsertCost(1) ->setDeleteCost(1) ->setReplaceCost(10); return self::correctSpelling($command, $options, $matrix, $max_distance); } public static function correctSpelling( $input, array $options, PhutilEditDistanceMatrix $matrix, $max_distance) { $distances = array(); $inputv = str_split($input); foreach ($options as $option) { $optionv = str_split($option); $matrix->setSequences($optionv, $inputv); $distances[$option] = $matrix->getEditDistance(); } asort($distances); $best = min($max_distance, reset($distances)); foreach ($distances as $option => $distance) { if ($distance > $best) { unset($distances[$option]); } } // Before filtering, check if we have multiple equidistant matches and // return them if we do. This prevents us from, e.g., matching "alnd" with // both "land" and "amend", then dropping "land" for being too short, and // incorrectly completing to "amend". if (count($distances) > 1) { return array_keys($distances); } foreach ($distances as $option => $distance) { if (strlen($option) < $distance) { unset($distances[$option]); } } return array_keys($distances); } } diff --git a/src/configuration/ArcanistConfigurationManager.php b/src/configuration/ArcanistConfigurationManager.php index 6195a1ec..d2284742 100644 --- a/src/configuration/ArcanistConfigurationManager.php +++ b/src/configuration/ArcanistConfigurationManager.php @@ -1,344 +1,344 @@ workingCopy = $working_copy; } /* -( Get config )--------------------------------------------------------- */ const CONFIG_SOURCE_RUNTIME = 'runtime'; const CONFIG_SOURCE_LOCAL = 'local'; const CONFIG_SOURCE_PROJECT = 'project'; const CONFIG_SOURCE_USER = 'user'; const CONFIG_SOURCE_SYSTEM = 'system'; const CONFIG_SOURCE_DEFAULT = 'default'; public function getProjectConfig($key) { if ($this->workingCopy) { return $this->workingCopy->getProjectConfig($key); } return null; } public function getLocalConfig($key) { if ($this->workingCopy) { return $this->workingCopy->getLocalConfig($key); } return null; } public function getWorkingCopyIdentity() { return $this->workingCopy; } /** * Read a configuration directive from any available configuration source. * This includes the directive in local, user and system configuration in * addition to project configuration, and configuration provided as command * arguments ("runtime"). * The precedence is runtime > local > project > user > system * * @param key Key to read. * @param wild Default value if key is not found. * @return wild Value, or default value if not found. * * @task config */ public function getConfigFromAnySource($key, $default = null) { $all = $this->getConfigFromAllSources($key); return empty($all) ? $default : head($all); } /** * For the advanced case where you want customized configuration handling. * * Reads the configuration from all available sources, returning a map (array) * of results, with the source as key. Missing values will not be in the map, * so an empty array will be returned if no results are found. * * The map is ordered by the canonical sources precedence, which is: * runtime > local > project > user > system * * @param key Key to read * @return array Mapping of source => value read. Sources with no value are * not in the array. * * @task config */ public function getConfigFromAllSources($key) { $results = array(); $settings = new ArcanistSettings(); $pval = idx($this->runtimeConfig, $key); if ($pval !== null) { $results[self::CONFIG_SOURCE_RUNTIME] = $settings->willReadValue($key, $pval); } $pval = $this->getLocalConfig($key); if ($pval !== null) { $results[self::CONFIG_SOURCE_LOCAL] = $settings->willReadValue($key, $pval); } $pval = $this->getProjectConfig($key); if ($pval !== null) { $results[self::CONFIG_SOURCE_PROJECT] = $settings->willReadValue($key, $pval); } $user_config = $this->readUserArcConfig(); $pval = idx($user_config, $key); if ($pval !== null) { $results[self::CONFIG_SOURCE_USER] = $settings->willReadValue($key, $pval); } $system_config = $this->readSystemArcConfig(); $pval = idx($system_config, $key); if ($pval !== null) { $results[self::CONFIG_SOURCE_SYSTEM] = $settings->willReadValue($key, $pval); } $default_config = $this->readDefaultConfig(); if (array_key_exists($key, $default_config)) { $results[self::CONFIG_SOURCE_DEFAULT] = $default_config[$key]; } return $results; } /** * Sets a runtime config value that takes precedence over any static * config values. * * @param key Key to set. * @param value The value of the key. * * @task config */ public function setRuntimeConfig($key, $value) { $this->runtimeConfig[$key] = $value; return $this; } /* -( Read/write config )--------------------------------------------------- */ public function readLocalArcConfig() { if ($this->workingCopy) { return $this->workingCopy->readLocalArcConfig(); } return array(); } public function writeLocalArcConfig(array $config) { if ($this->workingCopy) { return $this->workingCopy->writeLocalArcConfig($config); } throw new Exception(pht('No working copy to write config to!')); } /** * This is probably not the method you're looking for; try * @{method:readUserArcConfig}. */ public function readUserConfigurationFile() { if ($this->userConfigCache === null) { $user_config = array(); $user_config_path = $this->getUserConfigurationFileLocation(); $console = PhutilConsole::getConsole(); if (Filesystem::pathExists($user_config_path)) { $console->writeLog( "%s\n", pht( 'Config: Reading user configuration file "%s"...', $user_config_path)); if (!phutil_is_windows()) { $mode = fileperms($user_config_path); if (!$mode) { throw new Exception( pht( 'Unable to read file permissions for "%s"!', $user_config_path)); } if ($mode & 0177) { // Mode should allow only owner access. $prompt = pht( "File permissions on your %s are too open. ". "Fix them by chmod'ing to 600?", '~/.arcrc'); if (!phutil_console_confirm($prompt, $default_no = false)) { throw new ArcanistUsageException( pht('Set %s to file mode 600.', '~/.arcrc')); } execx('chmod 600 %s', $user_config_path); // Drop the stat cache so we don't read the old permissions if // we end up here again. If we don't do this, we may prompt the user // to fix permissions multiple times. clearstatcache(); } } $user_config_data = Filesystem::readFile($user_config_path); try { $user_config = phutil_json_decode($user_config_data); } catch (PhutilJSONParserException $ex) { throw new PhutilProxyException( pht("Your '%s' file is not a valid JSON file.", '~/.arcrc'), $ex); } } else { $console->writeLog( "%s\n", pht( 'Config: Did not find user configuration at "%s".', $user_config_path)); } $this->userConfigCache = $user_config; } return $this->userConfigCache; } /** * This is probably not the method you're looking for; try * @{method:writeUserArcConfig}. */ public function writeUserConfigurationFile($config) { $json_encoder = new PhutilJSON(); $json = $json_encoder->encodeFormatted($config); $path = $this->getUserConfigurationFileLocation(); Filesystem::writeFile($path, $json); if (!phutil_is_windows()) { execx('chmod 600 %s', $path); } } public function setUserConfigurationFileLocation($custom_arcrc) { if (!Filesystem::pathExists($custom_arcrc)) { throw new Exception( pht('Custom %s file was specified, but it was not found!', 'arcrc')); } $this->customArcrcFilename = $custom_arcrc; $this->userConfigCache = null; } public function getUserConfigurationFileLocation() { if (strlen($this->customArcrcFilename)) { return $this->customArcrcFilename; } if (phutil_is_windows()) { return getenv('APPDATA').'/.arcrc'; } else { return getenv('HOME').'/.arcrc'; } } public function readUserArcConfig() { return idx($this->readUserConfigurationFile(), 'config', array()); } public function writeUserArcConfig(array $options) { $config = $this->readUserConfigurationFile(); $config['config'] = $options; $this->writeUserConfigurationFile($config); } public function getSystemArcConfigLocation() { if (phutil_is_windows()) { return Filesystem::resolvePath( 'Phabricator/Arcanist/config', getenv('ProgramData')); } else { return '/etc/arcconfig'; } } public function readSystemArcConfig() { static $system_config; if ($system_config === null) { $system_config = array(); $system_config_path = $this->getSystemArcConfigLocation(); $console = PhutilConsole::getConsole(); if (Filesystem::pathExists($system_config_path)) { $console->writeLog( "%s\n", pht( 'Config: Reading system configuration file "%s"...', $system_config_path)); $file = Filesystem::readFile($system_config_path); try { $system_config = phutil_json_decode($file); } catch (PhutilJSONParserException $ex) { throw new PhutilProxyException( pht( "Your '%s' file is not a valid JSON file.", $system_config_path), $ex); } } else { $console->writeLog( "%s\n", pht( 'Config: Did not find system configuration at "%s".', $system_config_path)); } } return $system_config; } public function applyRuntimeArcConfig($args) { $arcanist_settings = new ArcanistSettings(); $options = $args->getArg('config'); foreach ($options as $opt) { $opt_config = preg_split('/=/', $opt, 2); if (count($opt_config) !== 2) { throw new ArcanistUsageException( pht( "Argument was '%s', but must be '%s'. For example, %s", $opt, 'name=value', 'history.immutable=true')); } list($key, $value) = $opt_config; $value = $arcanist_settings->willWriteValue($key, $value); $this->setRuntimeConfig($key, $value); } return $this->runtimeConfig; } public function readDefaultConfig() { $settings = new ArcanistSettings(); return $settings->getDefaultSettings(); } } diff --git a/src/configuration/ArcanistSettings.php b/src/configuration/ArcanistSettings.php index cd5f8f01..d7314c1d 100644 --- a/src/configuration/ArcanistSettings.php +++ b/src/configuration/ArcanistSettings.php @@ -1,338 +1,338 @@ array( 'type' => 'string', 'help' => pht( 'The URI of a Phabricator install to connect to by default, if '. '%s is run in a project without a Phabricator URI or run outside '. 'of a project.', 'arc'), 'example' => '"http://phabricator.example.com/"', ), 'base' => array( 'type' => 'string', 'help' => pht( 'Base commit ruleset to invoke when determining the start of a '. 'commit range. See "Arcanist User Guide: Commit Ranges" for '. 'details.'), 'example' => '"arc:amended, arc:prompt"', ), 'load' => array( 'type' => 'list', 'legacy' => 'phutil_libraries', 'help' => pht( 'A list of paths to phutil libraries that should be loaded at '. 'startup. This can be used to make classes available, like lint '. 'or unit test engines.'), 'default' => array(), 'example' => '["/var/arc/customlib/src"]', ), 'repository.callsign' => array( 'type' => 'string', 'example' => '"X"', 'help' => pht( 'Associate the working copy with a specific Phabricator repository. '. 'Normally, %s can figure this association out on its own, but if '. 'your setup is unusual you can use this option to tell it what the '. 'desired value is.', 'arc'), ), 'phabricator.uri' => array( 'type' => 'string', 'legacy' => 'conduit_uri', 'example' => '"https://phabricator.mycompany.com/"', 'help' => pht( 'Associates this working copy with a specific installation of '. 'Phabricator.'), ), 'lint.engine' => array( 'type' => 'string', 'legacy' => 'lint_engine', 'help' => pht( 'The name of a default lint engine to use, if no lint engine is '. 'specified by the current project.'), 'example' => '"ExampleLintEngine"', ), 'unit.engine' => array( 'type' => 'string', 'legacy' => 'unit_engine', 'help' => pht( 'The name of a default unit test engine to use, if no unit test '. 'engine is specified by the current project.'), 'example' => '"ExampleUnitTestEngine"', ), 'arc.feature.start.default' => array( 'type' => 'string', 'help' => pht( 'The name of the default branch to create the new feature branch '. 'off of.'), 'example' => '"develop"', ), 'arc.land.onto.default' => array( 'type' => 'string', 'help' => pht( 'The name of the default branch to land changes onto when '. '`%s` is run.', 'arc land'), 'example' => '"develop"', ), 'arc.land.update.default' => array( 'type' => 'string', 'help' => pht( 'The default strategy to use when arc land updates the feature '. 'branch. Supports "rebase" and "merge" strategies.'), 'example' => '"rebase"', ), 'arc.lint.cache' => array( 'type' => 'bool', 'help' => pht( 'Enable the lint cache by default. When enabled, `%s` attempts to '. 'use cached results if possible. Currently, the cache is not always '. 'invalidated correctly and may cause `%s` to report incorrect '. 'results, particularly while developing linters. This is probably '. 'worth enabling only if your linters are very slow.', 'arc lint', 'arc lint'), 'default' => false, 'example' => 'false', ), 'history.immutable' => array( 'type' => 'bool', 'legacy' => 'immutable_history', 'help' => pht( 'If true, %s will never change repository history (e.g., through '. 'amending or rebasing). Defaults to true in Mercurial and false in '. 'Git. This setting has no effect in Subversion.', 'arc'), 'example' => 'false', ), 'editor' => array( 'type' => 'string', 'help' => pht( 'Command to use to invoke an interactive editor, like `%s` or `%s`. '. 'This setting overrides the %s environmental variable.', 'nano', 'vim', 'EDITOR'), 'example' => '"nano"', ), 'https.cabundle' => array( 'type' => 'string', 'help' => pht( "Path to a custom CA bundle file to be used for arcanist's cURL ". "calls. This is used primarily when your conduit endpoint is ". "behind HTTPS signed by your organization's internal CA."), 'example' => 'support/yourca.pem', ), 'https.blindly-trust-domains' => array( 'type' => 'list', 'help' => pht( 'List of domains to blindly trust SSL certificates for. '. 'Disables peer verification.'), 'default' => array(), 'example' => '["secure.mycompany.com"]', ), 'browser' => array( 'type' => 'string', 'help' => pht('Command to use to invoke a web browser.'), 'example' => '"gnome-www-browser"', ), 'events.listeners' => array( 'type' => 'list', 'help' => pht('List of event listener classes to install at startup.'), 'default' => array(), 'example' => '["ExampleEventListener"]', ), 'http.basicauth.user' => array( 'type' => 'string', 'help' => pht('Username to use for basic auth over HTTP transports.'), 'example' => '"bob"', ), 'http.basicauth.pass' => array( 'type' => 'string', 'help' => pht('Password to use for basic auth over HTTP transports.'), 'example' => '"bobhasasecret"', ), 'arc.autostash' => array( 'type' => 'bool', 'help' => pht( 'Whether %s should permit the automatic stashing of changes in the '. 'working directory when requiring a clean working copy. This option '. 'should only be used when users understand how to restore their '. 'working directory from the local stash if an Arcanist operation '. 'causes an unrecoverable error.', 'arc'), 'default' => false, 'example' => 'false', ), ); } private function getOption($key) { return idx($this->getOptions(), $key, array()); } public function getAllKeys() { return array_keys($this->getOptions()); } public function getHelp($key) { return idx($this->getOption($key), 'help'); } public function getExample($key) { return idx($this->getOption($key), 'example'); } public function getType($key) { return idx($this->getOption($key), 'type', 'wild'); } public function getLegacyName($key) { return idx($this->getOption($key), 'legacy'); } public function getDefaultSettings() { $defaults = array(); foreach ($this->getOptions() as $key => $option) { if (array_key_exists('default', $option)) { $defaults[$key] = $option['default']; } } return $defaults; } public function willWriteValue($key, $value) { $type = $this->getType($key); switch ($type) { case 'bool': if (strtolower($value) === 'false' || strtolower($value) === 'no' || strtolower($value) === 'off' || $value === '' || $value === '0' || $value === 0 || $value === false) { $value = false; } else if (strtolower($value) === 'true' || strtolower($value) === 'yes' || strtolower($value) === 'on' || $value === '1' || $value === 1 || $value === true) { $value = true; } else { throw new ArcanistUsageException( pht( "Type of setting '%s' must be boolean, like 'true' or 'false'.", $key)); } break; case 'list': if (is_array($value)) { break; } if (is_string($value)) { $list = json_decode($value, true); if (is_array($list)) { $value = $list; break; } } throw new ArcanistUsageException( pht( "Type of setting '%s' must be list. You can specify a list ". "in JSON, like: %s", $key, '["apple", "banana", "cherry"]')); case 'string': if (!is_scalar($value)) { throw new ArcanistUsageException( pht( "Type of setting '%s' must be string.", $key)); } $value = (string)$value; break; case 'wild': break; } return $value; } public function willReadValue($key, $value) { $type = $this->getType($key); switch ($type) { case 'string': if (!is_string($value)) { throw new ArcanistUsageException( pht( "Type of setting '%s' must be string.", $key)); } break; case 'bool': if ($value !== true && $value !== false) { throw new ArcanistUsageException( pht( "Type of setting '%s' must be boolean.", $key)); } break; case 'list': if (!is_array($value)) { throw new ArcanistUsageException( pht( "Type of setting '%s' must be list.", $key)); } break; case 'wild': break; } return $value; } public function formatConfigValueForDisplay($key, $value) { if ($value === false) { return 'false'; } if ($value === true) { return 'true'; } if ($value === null) { return 'null'; } if (is_string($value)) { return '"'.$value.'"'; } if (is_array($value)) { // TODO: Both json_encode() and PhutilJSON do a bad job with one-liners. // PhutilJSON splits them across a bunch of lines, while json_encode() // escapes all kinds of stuff like "/". It would be nice if PhutilJSON // had a mode for pretty one-liners. $value = json_encode($value); // json_encode() unnecessarily escapes "/" to prevent "" stuff, // optimistically unescape it for display to improve readability. $value = preg_replace('@(?'; $highlight_c = ''; $is_html = false; if ($str instanceof PhutilSafeHTML) { $is_html = true; $str = $str->getHTMLContent(); } $n = strlen($str); for ($i = 0; $i < $n; $i++) { if ($p == $e) { do { if (empty($intra_stack)) { $buf .= substr($str, $i); break 2; } $stack = array_shift($intra_stack); $s = $e; $e += $stack[1]; } while ($stack[0] == 0); } if (!$highlight && !$tag && !$ent && $p == $s) { $buf .= $highlight_o; $highlight = true; } if ($str[$i] == '<') { $tag = true; if ($highlight) { $buf .= $highlight_c; } } if (!$tag) { if ($str[$i] == '&') { $ent = true; } if ($ent && $str[$i] == ';') { $ent = false; } if (!$ent) { $p++; } } $buf .= $str[$i]; if ($tag && $str[$i] == '>') { $tag = false; if ($highlight) { $buf .= $highlight_o; } } if ($highlight && ($p == $e || $i == $n - 1)) { $buf .= $highlight_c; $highlight = false; } } if ($is_html) { return phutil_safe_html($buf); } return $buf; } private static function collapseIntralineRuns($runs) { $count = count($runs); for ($ii = 0; $ii < $count - 1; $ii++) { if ($runs[$ii][0] == $runs[$ii + 1][0]) { $runs[$ii + 1][1] += $runs[$ii][1]; unset($runs[$ii]); } } return array_values($runs); } public static function generateEditString(array $ov, array $nv, $max = 80) { return id(new PhutilEditDistanceMatrix()) ->setComputeString(true) ->setAlterCost(1 / ($max * 2)) ->setReplaceCost(2) ->setMaximumLength($max) ->setSequences($ov, $nv) ->getEditString(); } public static function computeIntralineEdits($o, $n) { if (preg_match('/[\x80-\xFF]/', $o.$n)) { $ov = phutil_utf8v_combined($o); $nv = phutil_utf8v_combined($n); $multibyte = true; } else { $ov = str_split($o); $nv = str_split($n); $multibyte = false; } $result = self::generateEditString($ov, $nv); // Smooth the string out, by replacing short runs of similar characters // with 'x' operations. This makes the result more readable to humans, since // there are fewer choppy runs of short added and removed substrings. do { $original = $result; $result = preg_replace( '/([xdi])(s{3})([xdi])/', '$1xxx$3', $result); $result = preg_replace( '/([xdi])(s{2})([xdi])/', '$1xx$3', $result); $result = preg_replace( '/([xdi])(s{1})([xdi])/', '$1x$3', $result); } while ($result != $original); // Now we have a character-based description of the edit. We need to // convert into a byte-based description. Walk through the edit string and // adjust each operation to reflect the number of bytes in the underlying // character. $o_pos = 0; $n_pos = 0; $result_len = strlen($result); $o_run = array(); $n_run = array(); $old_char_len = 1; $new_char_len = 1; for ($ii = 0; $ii < $result_len; $ii++) { $c = $result[$ii]; if ($multibyte) { $old_char_len = strlen($ov[$o_pos]); $new_char_len = strlen($nv[$n_pos]); } switch ($c) { case 's': case 'x': $byte_o = $old_char_len; $byte_n = $new_char_len; $o_pos++; $n_pos++; break; case 'i': $byte_o = 0; $byte_n = $new_char_len; $n_pos++; break; case 'd': $byte_o = $old_char_len; $byte_n = 0; $o_pos++; break; } if ($byte_o) { if ($c == 's') { $o_run[] = array(0, $byte_o); } else { $o_run[] = array(1, $byte_o); } } if ($byte_n) { if ($c == 's') { $n_run[] = array(0, $byte_n); } else { $n_run[] = array(1, $byte_n); } } } $o_run = self::collapseIntralineRuns($o_run); $n_run = self::collapseIntralineRuns($n_run); return array($o_run, $n_run); } } diff --git a/src/differential/ArcanistDifferentialCommitMessage.php b/src/differential/ArcanistDifferentialCommitMessage.php index 0fa26e4b..b76011f5 100644 --- a/src/differential/ArcanistDifferentialCommitMessage.php +++ b/src/differential/ArcanistDifferentialCommitMessage.php @@ -1,134 +1,134 @@ rawCorpus = $corpus; $obj->revisionID = $obj->parseRevisionIDFromRawCorpus($corpus); $pattern = '/^git-svn-id:\s*([^@]+)@(\d+)\s+(.*)$/m'; $match = null; if (preg_match($pattern, $corpus, $match)) { $obj->gitSVNBaseRevision = $match[1].'@'.$match[2]; $obj->gitSVNBasePath = $match[1]; $obj->gitSVNUUID = $match[3]; } return $obj; } public function getRawCorpus() { return $this->rawCorpus; } public function getRevisionID() { return $this->revisionID; } public function pullDataFromConduit( ConduitClient $conduit, $partial = false) { $result = $conduit->callMethodSynchronous( 'differential.parsecommitmessage', array( 'corpus' => $this->rawCorpus, 'partial' => $partial, )); $this->fields = $result['fields']; if (!empty($result['errors'])) { throw new ArcanistDifferentialCommitMessageParserException( $result['errors']); } return $this; } public function getFieldValue($key) { if (array_key_exists($key, $this->fields)) { return $this->fields[$key]; } return null; } public function setFieldValue($key, $value) { $this->fields[$key] = $value; return $this; } public function getFields() { return $this->fields; } public function getGitSVNBaseRevision() { return $this->gitSVNBaseRevision; } public function getGitSVNBasePath() { return $this->gitSVNBasePath; } public function getGitSVNUUID() { return $this->gitSVNUUID; } public function getChecksum() { $fields = array_filter($this->fields); ksort($fields); $fields = json_encode($fields); return md5($fields); } /** * Extract the revision ID from a commit message. * * @param string Raw commit message. * @return int|null Revision ID, if the commit message contains one. */ private function parseRevisionIDFromRawCorpus($corpus) { $match = null; if (!preg_match('/^Differential Revision:\s*(.+)/im', $corpus, $match)) { return null; } $revision_value = trim($match[1]); $revision_pattern = '/^[dD]([1-9]\d*)\z/'; // Accept a bare revision ID like "D123". if (preg_match($revision_pattern, $revision_value, $match)) { return (int)$match[1]; } // Otherwise, try to find a full URI. $uri = new PhutilURI($revision_value); $path = $uri->getPath(); $path = trim($path, '/'); if (preg_match($revision_pattern, $path, $match)) { return (int)$match[1]; } throw new ArcanistUsageException( pht( 'Invalid "Differential Revision" field in commit message. This field '. 'should have a revision identifier like "%s" or a Phabricator URI '. 'like "%s", but has "%s".', 'D123', 'https://phabricator.example.com/D123', $revision_value)); } } diff --git a/src/differential/constants/ArcanistDifferentialRevisionHash.php b/src/differential/constants/ArcanistDifferentialRevisionHash.php index 4763ab42..123867d4 100644 --- a/src/differential/constants/ArcanistDifferentialRevisionHash.php +++ b/src/differential/constants/ArcanistDifferentialRevisionHash.php @@ -1,19 +1,19 @@ pht('Needs Review'), self::NEEDS_REVISION => pht('Needs Revision'), self::ACCEPTED => pht('Accepted'), self::CLOSED => pht('Closed'), self::ABANDONED => pht('Abandoned'), self::CHANGES_PLANNED => pht('Changes Planned'), self::IN_PREPARATION => pht('In Preparation'), ); return idx($map, coalesce($status, '?'), pht('Unknown')); } } diff --git a/src/hgdaemon/ArcanistHgProxyClient.php b/src/hgdaemon/ArcanistHgProxyClient.php index 86053fc4..6aa00e74 100644 --- a/src/hgdaemon/ArcanistHgProxyClient.php +++ b/src/hgdaemon/ArcanistHgProxyClient.php @@ -1,200 +1,200 @@ executeCommand($command); * * The advantage of using this complex mechanism is that commands run in this * way do not need to pay the startup overhead for hg and the Python runtime, * which is often on the order of 100ms or more per command. * * @task construct Construction * @task config Configuration * @task exec Executing Mercurial Commands * @task internal Internals */ -final class ArcanistHgProxyClient { +final class ArcanistHgProxyClient extends Phobject { private $workingCopy; private $server; private $skipHello; /* -( Construction )------------------------------------------------------- */ /** * Build a new client. This client is bound to a working copy. A server * must already be running on this working copy for the client to work. * * @param string Path to a Mercurial working copy. * * @task construct */ public function __construct($working_copy) { $this->workingCopy = Filesystem::resolvePath($working_copy); } /* -( Configuration )------------------------------------------------------ */ /** * When connecting, do not expect the "capabilities" message. * * @param bool True to skip the "capabilities" message. * @return this * * @task config */ public function setSkipHello($skip) { $this->skipHello = $skip; return $this; } /* -( Executing Merucurial Commands )-------------------------------------- */ /** * Execute a command (given as a list of arguments) via the command server. * * @param list A list of command arguments, like "log", "-l", "5". * @return tuple Return code, stdout and stderr. * * @task exec */ public function executeCommand(array $argv) { if (!$this->server) { try { $server = $this->connectToDaemon(); } catch (Exception $ex) { $this->launchDaemon(); $server = $this->connectToDaemon(); } $this->server = $server; } $server = $this->server; // Note that we're adding "runcommand" to make the server run the command. // Theoretically the server supports other capabilities, but in practice // we are only concerned with "runcommand". $server->write(array_merge(array('runcommand'), $argv)); // We'll get back one or more blocks of response data, ending with an 'r' // block which indicates the return code. Reconstitute these into stdout, // stderr and a return code. $stdout = ''; $stderr = ''; $err = 0; $done = false; while ($message = $server->waitForMessage()) { // The $server channel handles decoding of the wire format and gives us // messages which look like this: // // array('o', ''); list($channel, $data) = $message; switch ($channel) { case 'o': $stdout .= $data; break; case 'e': $stderr .= $data; break; case 'd': // TODO: Do something with this? This is the 'debug' channel. break; case 'r': // NOTE: This little dance is because the value is emitted as a // big-endian signed 32-bit long. PHP has no flag to unpack() that // can unpack these, so we unpack a big-endian unsigned long, then // repack it as a machine-order unsigned long, then unpack it as // a machine-order signed long. This appears to produce the desired // result. $err = head(unpack('N', $data)); $err = pack('L', $err); $err = head(unpack('l', $err)); $done = true; break; } if ($done) { break; } } return array($err, $stdout, $stderr); } /* -( Internals )---------------------------------------------------------- */ /** * @task internal */ private function connectToDaemon() { $errno = null; $errstr = null; $socket_path = ArcanistHgProxyServer::getPathToSocket($this->workingCopy); $socket = @stream_socket_client('unix://'.$socket_path, $errno, $errstr); if ($errno || !$socket) { throw new Exception( pht( 'Unable to connect socket! Error #%d: %s', $errno, $errstr)); } $channel = new PhutilSocketChannel($socket); $server = new ArcanistHgServerChannel($channel); if (!$this->skipHello) { // The protocol includes a "hello" message with capability and encoding // information. Read and discard it, we use only the "runcommand" // capability which is guaranteed to be available. $hello = $server->waitForMessage(); } return $server; } /** * @task internal */ private function launchDaemon() { $root = dirname(phutil_get_library_root('arcanist')); $bin = $root.'/scripts/hgdaemon/hgdaemon_server.php'; $proxy = new ExecFuture( '%s %s --idle-limit 15 --quiet %C', $bin, $this->workingCopy, $this->skipHello ? '--skip-hello' : null); $proxy->resolvex(); } } diff --git a/src/hgdaemon/ArcanistHgProxyServer.php b/src/hgdaemon/ArcanistHgProxyServer.php index 9a2e8eb0..d0d40a9b 100644 --- a/src/hgdaemon/ArcanistHgProxyServer.php +++ b/src/hgdaemon/ArcanistHgProxyServer.php @@ -1,496 +1,496 @@ workingCopy = Filesystem::resolvePath($working_copy); } /* -( Configuration )------------------------------------------------------ */ /** * Disable status messages to stdout. Controlled with `--quiet`. * * @param bool True to disable status messages. * @return this * * @task config */ public function setQuiet($quiet) { $this->quiet = $quiet; return $this; } /** * Configure a client limit. After serving this many clients, the server * will exit. Controlled with `--client-limit`. * * You can use `--client-limit 1` with `--xprofile` and `--do-not-daemonize` * to profile the server. * * @param int Client limit, or 0 to disable limit. * @return this * * @task config */ public function setClientLimit($limit) { $this->clientLimit = $limit; return $this; } /** * Configure an idle time limit. After this many seconds idle, the server * will exit. Controlled with `--idle-limit`. * * @param int Idle limit, or 0 to disable limit. * @return this * * @task config */ public function setIdleLimit($limit) { $this->idleLimit = $limit; return $this; } /** * When clients connect, do not send the "capabilities" message expected by * the Mercurial protocol. This deviates from the protocol and will only work * if the clients are also configured not to expect the message, but slightly * improves performance. Controlled with --skip-hello. * * @param bool True to skip the "capabilities" message. * @return this * * @task config */ public function setSkipHello($skip) { $this->skipHello = $skip; return $this; } /** * Configure whether the server runs in the foreground or daemonizes. * Controlled by --do-not-daemonize. Primarily useful for debugging. * * @param bool True to run in the foreground. * @return this * * @task config */ public function setDoNotDaemonize($do_not_daemonize) { $this->doNotDaemonize = $do_not_daemonize; return $this; } /* -( Serving Requests )--------------------------------------------------- */ /** * Start the server. This method returns after the client limit or idle * limit are exceeded. If neither limit is configured, this method does not * exit. * * @return null * * @task server */ public function start() { // Create the unix domain socket in the working copy to listen for clients. $socket = $this->startWorkingCopySocket(); $this->socket = $socket; if (!$this->doNotDaemonize) { $this->daemonize(); } // Start the Mercurial process which we'll forward client requests to. $hg = $this->startMercurialProcess(); $clients = array(); $this->log(null, pht('Listening')); $this->idleSince = time(); while (true) { // Wait for activity on any active clients, the Mercurial process, or // the listening socket where new clients connect. PhutilChannel::waitForAny( array_merge($clients, array($hg)), array( 'read' => $socket ? array($socket) : array(), 'except' => $socket ? array($socket) : array(), )); if (!$hg->update()) { throw new Exception(pht('Server exited unexpectedly!')); } // Accept any new clients. while ($socket && ($client = $this->acceptNewClient($socket))) { $clients[] = $client; $key = last_key($clients); $client->setName($key); $this->log($client, pht('Connected')); $this->idleSince = time(); // Check if we've hit the client limit. If there's a configured // client limit and we've hit it, stop accepting new connections // and close the socket. $this->lifetimeClientCount++; if ($this->clientLimit) { if ($this->lifetimeClientCount >= $this->clientLimit) { $this->closeSocket(); $socket = null; } } } // Update all the active clients. foreach ($clients as $key => $client) { if ($this->updateClient($client, $hg)) { // In this case, the client is still connected so just move on to // the next one. Otherwise we continue below and handle the // disconnect. continue; } $this->log($client, pht('Disconnected')); unset($clients[$key]); // If we have a client limit and we've served that many clients, exit. if ($this->clientLimit) { if ($this->lifetimeClientCount >= $this->clientLimit) { if (!$clients) { $this->log(null, pht('Exiting (Client Limit)')); return; } } } } // If we have an idle limit and haven't had any activity in at least // that long, exit. if ($this->idleLimit) { $remaining = $this->idleLimit - (time() - $this->idleSince); if ($remaining <= 0) { $this->log(null, pht('Exiting (Idle Limit)')); return; } if ($remaining <= 5) { $this->log(null, pht('Exiting in %d seconds', $remaining)); } } } } /** * Update one client, processing any commands it has sent us. We fully * process all commands we've received here before returning to the main * server loop. * * @param ArcanistHgClientChannel The client to update. * @param ArcanistHgServerChannel The Mercurial server. * * @task server */ private function updateClient( ArcanistHgClientChannel $client, ArcanistHgServerChannel $hg) { if (!$client->update()) { // Client has disconnected, don't bother proceeding. return false; } // Read a command from the client if one is available. Note that we stop // updating other clients or accepting new connections while processing a // command, since there isn't much we can do with them until the server // finishes executing this command. $message = $client->read(); if (!$message) { return true; } $this->log($client, '$ '.$message[0].' '.$message[1]); $t_start = microtime(true); // Forward the command to the server. $hg->write($message); while (true) { PhutilChannel::waitForAny(array($client, $hg)); if (!$client->update() || !$hg->update()) { // If either the client or server has exited, bail. return false; } $response = $hg->read(); if (!$response) { continue; } // Forward the response back to the client. $client->write($response); // If the response was on the 'r'esult channel, it indicates the end // of the command output. We can process the next command (if any // remain) or go back to accepting new connections and servicing // other clients. if ($response[0] == 'r') { // Update the client immediately to try to get the bytes on the wire // as quickly as possible. This gives us slightly more throughput. $client->update(); break; } } // Log the elapsed time. $t_end = microtime(true); $t = 1000000 * ($t_end - $t_start); $this->log($client, pht('< %sus', number_format($t, 0))); $this->idleSince = time(); return true; } /* -( Managing Clients )--------------------------------------------------- */ /** * @task client */ public static function getPathToSocket($working_copy) { return $working_copy.'/.hg/hgdaemon-socket'; } /** * @task client */ private function startWorkingCopySocket() { $errno = null; $errstr = null; $socket_path = self::getPathToSocket($this->workingCopy); $socket_uri = 'unix://'.$socket_path; $socket = @stream_socket_server($socket_uri, $errno, $errstr); if ($errno || !$socket) { Filesystem::remove($socket_path); $socket = @stream_socket_server($socket_uri, $errno, $errstr); } if ($errno || !$socket) { throw new Exception( pht( 'Unable to start socket! Error #%d: %s', $errno, $errstr)); } $ok = stream_set_blocking($socket, 0); if ($ok === false) { throw new Exception(pht('Unable to set socket nonblocking!')); } return $socket; } /** * @task client */ private function acceptNewClient($socket) { // NOTE: stream_socket_accept() always blocks, even when the socket has // been set nonblocking. $new_client = @stream_socket_accept($socket, $timeout = 0); if (!$new_client) { return null; } $channel = new PhutilSocketChannel($new_client); $client = new ArcanistHgClientChannel($channel); if (!$this->skipHello) { $client->write($this->hello); } return $client; } /* -( Managing Mercurial )------------------------------------------------- */ /** * Starts a Mercurial process which can actually handle requests. * * @return ArcanistHgServerChannel Channel to the Mercurial server. * @task hg */ private function startMercurialProcess() { // NOTE: "cmdserver.log=-" makes Mercurial use the 'd'ebug channel for // log messages. $future = new ExecFuture( 'HGPLAIN=1 hg --config cmdserver.log=- serve --cmdserver pipe'); $future->setCWD($this->workingCopy); $channel = new PhutilExecChannel($future); $hg = new ArcanistHgServerChannel($channel); // The server sends a "hello" message with capability and encoding // information. Save it and forward it to clients when they connect. $this->hello = $hg->waitForMessage(); return $hg; } /* -( Internals )---------------------------------------------------------- */ /** * Close and remove the unix domain socket in the working copy. * * @task internal */ public function __destruct() { $this->closeSocket(); } private function closeSocket() { if ($this->socket) { @stream_socket_shutdown($this->socket, STREAM_SHUT_RDWR); @fclose($this->socket); Filesystem::remove(self::getPathToSocket($this->workingCopy)); $this->socket = null; } } private function log($client, $message) { if ($this->quiet) { return; } if ($client) { $message = sprintf( '[%s] %s', pht('Client %s', $client->getName()), $message); } else { $message = sprintf( '[%s] %s', pht('Server'), $message); } echo $message."\n"; } private function daemonize() { // Keep stdout if it's been redirected somewhere, otherwise shut it down. $keep_stdout = false; $keep_stderr = false; if (function_exists('posix_isatty')) { if (!posix_isatty(STDOUT)) { $keep_stdout = true; } if (!posix_isatty(STDERR)) { $keep_stderr = true; } } $pid = pcntl_fork(); if ($pid === -1) { throw new Exception(pht('Unable to fork!')); } else if ($pid) { // We're the parent; exit. First, drop our reference to the socket so // our __destruct() doesn't tear it down; the child will tear it down // later. $this->socket = null; exit(0); } // We're the child; continue. fclose(STDIN); if (!$keep_stdout) { fclose(STDOUT); $this->quiet = true; } if (!$keep_stderr) { fclose(STDERR); } } } diff --git a/src/lint/ArcanistLintMessage.php b/src/lint/ArcanistLintMessage.php index 9d2e8911..8d7eb78b 100644 --- a/src/lint/ArcanistLintMessage.php +++ b/src/lint/ArcanistLintMessage.php @@ -1,228 +1,228 @@ setPath($dict['path']); $message->setLine($dict['line']); $message->setChar($dict['char']); $message->setCode($dict['code']); $message->setSeverity($dict['severity']); $message->setName($dict['name']); $message->setDescription($dict['description']); if (isset($dict['original'])) { $message->setOriginalText($dict['original']); } if (isset($dict['replacement'])) { $message->setReplacementText($dict['replacement']); } $message->setGranularity(idx($dict, 'granularity')); $message->setOtherLocations(idx($dict, 'locations', array())); if (isset($dict['bypassChangedLineFiltering'])) { $message->bypassChangedLineFiltering($dict['bypassChangedLineFiltering']); } return $message; } public function toDictionary() { return array( 'path' => $this->getPath(), 'line' => $this->getLine(), 'char' => $this->getChar(), 'code' => $this->getCode(), 'severity' => $this->getSeverity(), 'name' => $this->getName(), 'description' => $this->getDescription(), 'original' => $this->getOriginalText(), 'replacement' => $this->getReplacementText(), 'granularity' => $this->getGranularity(), 'locations' => $this->getOtherLocations(), 'bypassChangedLineFiltering' => $this->shouldBypassChangedLineFiltering(), ); } public function setPath($path) { $this->path = $path; return $this; } public function getPath() { return $this->path; } public function setLine($line) { $this->line = $line; return $this; } public function getLine() { return $this->line; } public function setChar($char) { $this->char = $char; return $this; } public function getChar() { return $this->char; } public function setCode($code) { $this->code = $code; return $this; } public function getCode() { return $this->code; } public function setSeverity($severity) { $this->severity = $severity; return $this; } public function getSeverity() { return $this->severity; } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function setDescription($description) { $this->description = $description; return $this; } public function getDescription() { return $this->description; } public function setOriginalText($original) { $this->originalText = $original; return $this; } public function getOriginalText() { return $this->originalText; } public function setReplacementText($replacement) { $this->replacementText = $replacement; return $this; } public function getReplacementText() { return $this->replacementText; } /** * @param dict Keys 'path', 'line', 'char', 'original'. */ public function setOtherLocations(array $locations) { assert_instances_of($locations, 'array'); $this->otherLocations = $locations; return $this; } public function getOtherLocations() { return $this->otherLocations; } public function isError() { return $this->getSeverity() == ArcanistLintSeverity::SEVERITY_ERROR; } public function isWarning() { return $this->getSeverity() == ArcanistLintSeverity::SEVERITY_WARNING; } public function isAutofix() { return $this->getSeverity() == ArcanistLintSeverity::SEVERITY_AUTOFIX; } public function hasFileContext() { return ($this->getLine() !== null); } public function setObsolete($obsolete) { $this->obsolete = $obsolete; return $this; } public function getObsolete() { return $this->obsolete; } public function isPatchable() { return ($this->getReplacementText() !== null) && ($this->getReplacementText() !== $this->getOriginalText()); } public function didApplyPatch() { if ($this->appliedToDisk) { return $this; } $this->appliedToDisk = true; foreach ($this->dependentMessages as $message) { $message->didApplyPatch(); } return $this; } public function isPatchApplied() { return $this->appliedToDisk; } public function setGranularity($granularity) { $this->granularity = $granularity; return $this; } public function getGranularity() { return $this->granularity; } public function setDependentMessages(array $messages) { assert_instances_of($messages, __CLASS__); $this->dependentMessages = $messages; return $this; } public function setBypassChangedLineFiltering($bypass_changed_lines) { $this->bypassChangedLineFiltering = $bypass_changed_lines; return $this; } public function shouldBypassChangedLineFiltering() { return $this->bypassChangedLineFiltering; } } diff --git a/src/lint/ArcanistLintPatcher.php b/src/lint/ArcanistLintPatcher.php index 5bbd2503..0147dd61 100644 --- a/src/lint/ArcanistLintPatcher.php +++ b/src/lint/ArcanistLintPatcher.php @@ -1,143 +1,143 @@ lintResult = $result; return $obj; } public function getUnmodifiedFileContent() { return $this->lintResult->getData(); } public function getModifiedFileContent() { if ($this->modifiedData === null) { $this->buildModifiedFile(); } return $this->modifiedData; } public function writePatchToDisk() { $path = $this->lintResult->getFilePathOnDisk(); $data = $this->getModifiedFileContent(); $ii = null; do { $lint = $path.'.linted'.($ii++); } while (file_exists($lint)); // Copy existing file to preserve permissions. 'chmod --reference' is not // supported under OSX. if (Filesystem::pathExists($path)) { // This path may not exist if we're generating a new file. execx('cp -p %s %s', $path, $lint); } Filesystem::writeFile($lint, $data); list($err) = exec_manual('mv -f %s %s', $lint, $path); if ($err) { throw new Exception( pht( "Unable to overwrite path '%s', patched version was left at '%s'.", $path, $lint)); } foreach ($this->applyMessages as $message) { $message->didApplyPatch(); } } private function __construct() {} private function buildModifiedFile() { $data = $this->getUnmodifiedFileContent(); foreach ($this->lintResult->getMessages() as $lint) { if (!$lint->isPatchable()) { continue; } $orig_offset = $this->getCharacterOffset($lint->getLine() - 1); $orig_offset += $lint->getChar() - 1; $dirty = $this->getDirtyCharacterOffset(); if ($dirty > $orig_offset) { continue; } // Adjust the character offset by the delta *after* checking for // dirtiness. The dirty character cursor is a cursor on the original file, // and should be compared with the patch position in the original file. $working_offset = $orig_offset + $this->getCharacterDelta(); $old_str = $lint->getOriginalText(); $old_len = strlen($old_str); $new_str = $lint->getReplacementText(); $new_len = strlen($new_str); if ($working_offset == strlen($data)) { // Temporary hack to work around a destructive hphpi issue, see #451031. $data .= $new_str; } else { $data = substr_replace($data, $new_str, $working_offset, $old_len); } $this->changeCharacterDelta($new_len - $old_len); $this->setDirtyCharacterOffset($orig_offset + $old_len); $this->applyMessages[] = $lint; } $this->modifiedData = $data; } private function getCharacterOffset($line_num) { if ($this->lineOffsets === null) { $lines = explode("\n", $this->getUnmodifiedFileContent()); $this->lineOffsets = array(0); $last = 0; foreach ($lines as $line) { $this->lineOffsets[] = $last + strlen($line) + 1; $last += strlen($line) + 1; } } if ($line_num >= count($this->lineOffsets)) { throw new Exception(pht('Data has fewer than %d lines.', $line)); } return idx($this->lineOffsets, $line_num); } private function setDirtyCharacterOffset($offset) { $this->dirtyUntil = $offset; return $this; } private function getDirtyCharacterOffset() { return $this->dirtyUntil; } private function changeCharacterDelta($change) { $this->characterDelta += $change; return $this; } private function getCharacterDelta() { return $this->characterDelta; } } diff --git a/src/lint/ArcanistLintResult.php b/src/lint/ArcanistLintResult.php index 69d819ad..f7a30a4c 100644 --- a/src/lint/ArcanistLintResult.php +++ b/src/lint/ArcanistLintResult.php @@ -1,104 +1,104 @@ path = $path; return $this; } public function getPath() { return $this->path; } public function addMessage(ArcanistLintMessage $message) { $this->messages[] = $message; $this->needsSort = true; return $this; } public function getMessages() { if ($this->needsSort) { $this->sortAndFilterMessages(); } return $this->effectiveMessages; } public function setData($data) { $this->data = $data; return $this; } public function getData() { return $this->data; } public function setFilePathOnDisk($file_path_on_disk) { $this->filePathOnDisk = $file_path_on_disk; return $this; } public function getFilePathOnDisk() { return $this->filePathOnDisk; } public function setCacheVersion($version) { $this->cacheVersion = $version; return $this; } public function getCacheVersion() { return $this->cacheVersion; } public function isPatchable() { foreach ($this->messages as $message) { if ($message->isPatchable()) { return true; } } return false; } public function isAllAutofix() { foreach ($this->messages as $message) { if (!$message->isAutofix()) { return false; } } return true; } public function sortAndFilterMessages() { $messages = $this->messages; foreach ($messages as $key => $message) { if ($message->getObsolete()) { unset($messages[$key]); continue; } } $map = array(); foreach ($messages as $key => $message) { $map[$key] = ($message->getLine() * (2 << 12)) + $message->getChar(); } asort($map); $messages = array_select_keys($messages, array_keys($map)); $this->effectiveMessages = $messages; $this->needsSort = false; } } diff --git a/src/lint/ArcanistLintSeverity.php b/src/lint/ArcanistLintSeverity.php index a8da738e..5926b328 100644 --- a/src/lint/ArcanistLintSeverity.php +++ b/src/lint/ArcanistLintSeverity.php @@ -1,50 +1,50 @@ pht('Advice'), self::SEVERITY_AUTOFIX => pht('Auto-Fix'), self::SEVERITY_WARNING => pht('Warning'), self::SEVERITY_ERROR => pht('Error'), self::SEVERITY_DISABLED => pht('Disabled'), ); } public static function getStringForSeverity($severity_code) { $map = self::getLintSeverities(); if (!array_key_exists($severity_code, $map)) { throw new Exception(pht("Unknown lint severity '%s'!", $severity_code)); } return $map[$severity_code]; } public static function isAtLeastAsSevere($message_sev, $level) { static $map = array( self::SEVERITY_DISABLED => 10, self::SEVERITY_ADVICE => 20, self::SEVERITY_AUTOFIX => 25, self::SEVERITY_WARNING => 30, self::SEVERITY_ERROR => 40, ); if (empty($map[$message_sev])) { return true; } return $map[$message_sev] >= idx($map, $level, 0); } } diff --git a/src/lint/engine/ArcanistLintEngine.php b/src/lint/engine/ArcanistLintEngine.php index c03f86e7..720af8ef 100644 --- a/src/lint/engine/ArcanistLintEngine.php +++ b/src/lint/engine/ArcanistLintEngine.php @@ -1,610 +1,610 @@ configurationManager = $configuration_manager; return $this; } final public function getConfigurationManager() { return $this->configurationManager; } final public function setWorkingCopy( ArcanistWorkingCopyIdentity $working_copy) { $this->workingCopy = $working_copy; return $this; } final public function getWorkingCopy() { return $this->workingCopy; } final public function setPaths($paths) { $this->paths = $paths; return $this; } public function getPaths() { return $this->paths; } final public function setPathChangedLines($path, $changed) { if ($changed === null) { $this->changedLines[$path] = null; } else { $this->changedLines[$path] = array_fill_keys($changed, true); } return $this; } final public function getPathChangedLines($path) { return idx($this->changedLines, $path); } final public function setFileData($data) { $this->fileData = $data + $this->fileData; return $this; } final public function setEnableAsyncLint($enable_async_lint) { $this->enableAsyncLint = $enable_async_lint; return $this; } final public function getEnableAsyncLint() { return $this->enableAsyncLint; } final public function loadData($path) { if (!isset($this->fileData[$path])) { $disk_path = $this->getFilePathOnDisk($path); $this->fileData[$path] = Filesystem::readFile($disk_path); } return $this->fileData[$path]; } public function pathExists($path) { $disk_path = $this->getFilePathOnDisk($path); return Filesystem::pathExists($disk_path); } final public function isDirectory($path) { $disk_path = $this->getFilePathOnDisk($path); return is_dir($disk_path); } final public function isBinaryFile($path) { try { $data = $this->loadData($path); } catch (Exception $ex) { return false; } return ArcanistDiffUtils::isHeuristicBinaryFile($data); } final public function isSymbolicLink($path) { return is_link($this->getFilePathOnDisk($path)); } final public function getFilePathOnDisk($path) { return Filesystem::resolvePath( $path, $this->getWorkingCopy()->getProjectRoot()); } final public function setMinimumSeverity($severity) { $this->minimumSeverity = $severity; return $this; } final public function run() { $linters = $this->buildLinters(); if (!$linters) { throw new ArcanistNoEffectException(pht('No linters to run.')); } foreach ($linters as $key => $linter) { $linter->setLinterID($key); } $linters = msort($linters, 'getLinterPriority'); foreach ($linters as $linter) { $linter->setEngine($this); } $have_paths = false; foreach ($linters as $linter) { if ($linter->getPaths()) { $have_paths = true; break; } } if (!$have_paths) { throw new ArcanistNoEffectException(pht('No paths are lintable.')); } $versions = array($this->getCacheVersion()); foreach ($linters as $linter) { $version = get_class($linter).':'.$linter->getCacheVersion(); $symbols = id(new PhutilSymbolLoader()) ->setType('class') ->setName(get_class($linter)) ->selectSymbolsWithoutLoading(); $symbol = idx($symbols, 'class$'.get_class($linter)); if ($symbol) { $version .= ':'.md5_file( phutil_get_library_root($symbol['library']).'/'.$symbol['where']); } $versions[] = $version; } $this->cacheVersion = crc32(implode("\n", $versions)); $runnable = $this->getRunnableLinters($linters); $this->stopped = array(); $exceptions = $this->executeLinters($runnable); foreach ($runnable as $linter) { foreach ($linter->getLintMessages() as $message) { if (!$this->isSeverityEnabled($message->getSeverity())) { continue; } if (!$this->isRelevantMessage($message)) { continue; } $message->setGranularity($linter->getCacheGranularity()); $result = $this->getResultForPath($message->getPath()); $result->addMessage($message); } } if ($this->cachedResults) { foreach ($this->cachedResults as $path => $messages) { $messages = idx($messages, $this->cacheVersion, array()); $repository_version = idx($messages, 'repository_version'); unset($messages['stopped']); unset($messages['repository_version']); foreach ($messages as $message) { $use_cache = $this->shouldUseCache( idx($message, 'granularity'), $repository_version); if ($use_cache) { $this->getResultForPath($path)->addMessage( ArcanistLintMessage::newFromDictionary($message)); } } } } foreach ($this->results as $path => $result) { $disk_path = $this->getFilePathOnDisk($path); $result->setFilePathOnDisk($disk_path); if (isset($this->fileData[$path])) { $result->setData($this->fileData[$path]); } else if ($disk_path && Filesystem::pathExists($disk_path)) { // TODO: this may cause us to, e.g., load a large binary when we only // raised an error about its filename. We could refine this by looking // through the lint messages and doing this load only if any of them // have original/replacement text or something like that. try { $this->fileData[$path] = Filesystem::readFile($disk_path); $result->setData($this->fileData[$path]); } catch (FilesystemException $ex) { // Ignore this, it's noncritical that we access this data and it // might be unreadable or a directory or whatever else for plenty // of legitimate reasons. } } } if ($exceptions) { throw new PhutilAggregateException( pht('Some linters failed:'), $exceptions); } return $this->results; } final public function isSeverityEnabled($severity) { $minimum = $this->minimumSeverity; return ArcanistLintSeverity::isAtLeastAsSevere($severity, $minimum); } final private function shouldUseCache( $cache_granularity, $repository_version) { switch ($cache_granularity) { case ArcanistLinter::GRANULARITY_FILE: return true; case ArcanistLinter::GRANULARITY_DIRECTORY: case ArcanistLinter::GRANULARITY_REPOSITORY: return ($this->repositoryVersion == $repository_version); default: return false; } } /** * @param dict>> * @return this */ final public function setCachedResults(array $results) { $this->cachedResults = $results; return $this; } final public function getResults() { return $this->results; } final public function getStoppedPaths() { return $this->stopped; } abstract public function buildLinters(); final public function setRepositoryVersion($version) { $this->repositoryVersion = $version; return $this; } final private function isRelevantMessage(ArcanistLintMessage $message) { // When a user runs "arc lint", we default to raising only warnings on // lines they have changed (errors are still raised anywhere in the // file). The list of $changed lines may be null, to indicate that the // path is a directory or a binary file so we should not exclude // warnings. if (!$this->changedLines || $message->isError() || $message->shouldBypassChangedLineFiltering()) { return true; } $locations = $message->getOtherLocations(); $locations[] = $message->toDictionary(); foreach ($locations as $location) { $path = idx($location, 'path', $message->getPath()); if (!array_key_exists($path, $this->changedLines)) { continue; } $changed = $this->getPathChangedLines($path); if ($changed === null || !$location['line']) { return true; } $last_line = $location['line']; if (isset($location['original'])) { $last_line += substr_count($location['original'], "\n"); } for ($l = $location['line']; $l <= $last_line; $l++) { if (!empty($changed[$l])) { return true; } } } return false; } final protected function getResultForPath($path) { if (empty($this->results[$path])) { $result = new ArcanistLintResult(); $result->setPath($path); $result->setCacheVersion($this->cacheVersion); $this->results[$path] = $result; } return $this->results[$path]; } final public function getLineAndCharFromOffset($path, $offset) { if (!isset($this->charToLine[$path])) { $char_to_line = array(); $line_to_first_char = array(); $lines = explode("\n", $this->loadData($path)); $line_number = 0; $line_start = 0; foreach ($lines as $line) { $len = strlen($line) + 1; // Account for "\n". $line_to_first_char[] = $line_start; $line_start += $len; for ($ii = 0; $ii < $len; $ii++) { $char_to_line[] = $line_number; } $line_number++; } $this->charToLine[$path] = $char_to_line; $this->lineToFirstChar[$path] = $line_to_first_char; } $line = $this->charToLine[$path][$offset]; $char = $offset - $this->lineToFirstChar[$path][$line]; return array($line, $char); } final public function getPostponedLinters() { return $this->postponedLinters; } final public function setPostponedLinters(array $linters) { $this->postponedLinters = $linters; return $this; } protected function getCacheVersion() { return 1; } /** * Get a named linter resource shared by another linter. * * This mechanism allows linters to share arbitrary resources, like the * results of computation. If several linters need to perform the same * expensive computation step, they can use a named resource to synchronize * construction of the result so it doesn't need to be built multiple * times. * * @param string Resource identifier. * @param wild Optionally, default value to return if resource does not * exist. * @return wild Resource, or default value if not present. */ public function getLinterResource($key, $default = null) { return idx($this->linterResources, $key, $default); } /** * Set a linter resource that other linters can access. * * See @{method:getLinterResource} for a description of this mechanism. * * @param string Resource identifier. * @param wild Resource. * @return this */ public function setLinterResource($key, $value) { $this->linterResources[$key] = $value; return $this; } private function getRunnableLinters(array $linters) { assert_instances_of($linters, 'ArcanistLinter'); // TODO: The canRun() mechanism is only used by one linter, and just // silently disables the linter. Almost every other linter handles this // by throwing `ArcanistMissingLinterException`. Both mechanisms are not // ideal; linters which can not run should emit a message, get marked as // "skipped", and allow execution to continue. See T7045. $runnable = array(); foreach ($linters as $key => $linter) { if ($linter->canRun()) { $runnable[$key] = $linter; } } return $runnable; } private function executeLinters(array $runnable) { $all_paths = $this->getPaths(); $path_chunks = array_chunk($all_paths, 32, $preserve_keys = true); $exception_lists = array(); foreach ($path_chunks as $chunk) { $exception_lists[] = $this->executeLintersOnChunk($runnable, $chunk); } return array_mergev($exception_lists); } private function executeLintersOnChunk(array $runnable, array $path_list) { assert_instances_of($runnable, 'ArcanistLinter'); $path_map = array_fuse($path_list); $exceptions = array(); $did_lint = array(); foreach ($runnable as $linter) { $linter_id = $linter->getLinterID(); $paths = $linter->getPaths(); foreach ($paths as $key => $path) { // If we aren't running this path in the current chunk of paths, // skip it completely. if (empty($path_map[$path])) { unset($paths[$key]); continue; } // Make sure each path has a result generated, even if it is empty // (i.e., the file has no lint messages). $result = $this->getResultForPath($path); // If a linter has stopped all other linters for this path, don't // actually run the linter. if (isset($this->stopped[$path])) { unset($paths[$key]); continue; } // If we have a cached result for this path, don't actually run the // linter. if (isset($this->cachedResults[$path][$this->cacheVersion])) { $cached_result = $this->cachedResults[$path][$this->cacheVersion]; $use_cache = $this->shouldUseCache( $linter->getCacheGranularity(), idx($cached_result, 'repository_version')); if ($use_cache) { unset($paths[$key]); if (idx($cached_result, 'stopped') == $linter_id) { $this->stopped[$path] = $linter_id; } } } } $paths = array_values($paths); if (!$paths) { continue; } try { $this->executeLinterOnPaths($linter, $paths); $did_lint[] = array($linter, $paths); } catch (Exception $ex) { $exceptions[] = $ex; } } foreach ($did_lint as $info) { list($linter, $paths) = $info; try { $this->executeDidLintOnPaths($linter, $paths); } catch (Exception $ex) { $exceptions[] = $ex; } } return $exceptions; } private function beginLintServiceCall(ArcanistLinter $linter, array $paths) { $profiler = PhutilServiceProfiler::getInstance(); return $profiler->beginServiceCall( array( 'type' => 'lint', 'linter' => $linter->getInfoName(), 'paths' => $paths, )); } private function endLintServiceCall($call_id) { $profiler = PhutilServiceProfiler::getInstance(); $profiler->endServiceCall($call_id, array()); } private function executeLinterOnPaths(ArcanistLinter $linter, array $paths) { $call_id = $this->beginLintServiceCall($linter, $paths); try { $linter->willLintPaths($paths); foreach ($paths as $path) { $linter->setActivePath($path); $linter->lintPath($path); if ($linter->didStopAllLinters()) { $this->stopped[$path] = $linter->getLinterID(); } } } catch (Exception $ex) { $this->endLintServiceCall($call_id); throw $ex; } $this->endLintServiceCall($call_id); } private function executeDidLintOnPaths(ArcanistLinter $linter, array $paths) { $call_id = $this->beginLintServiceCall($linter, $paths); try { $linter->didLintPaths($paths); } catch (Exception $ex) { $this->endLintServiceCall($call_id); throw $ex; } $this->endLintServiceCall($call_id); } } diff --git a/src/lint/linter/ArcanistLinter.php b/src/lint/linter/ArcanistLinter.php index 9d8e61a8..a344c9a4 100644 --- a/src/lint/linter/ArcanistLinter.php +++ b/src/lint/linter/ArcanistLinter.php @@ -1,623 +1,623 @@ getLinterName(), $this->getLinterConfigurationName(), get_class($this)); } /* -( Runtime State )------------------------------------------------------ */ /** * @task state */ final public function getActivePath() { return $this->activePath; } /** * @task state */ final public function setActivePath($path) { $this->stopAllLinters = false; $this->activePath = $path; return $this; } /** * @task state */ final public function setEngine(ArcanistLintEngine $engine) { $this->engine = $engine; return $this; } /** * @task state */ final protected function getEngine() { return $this->engine; } /** * Set the internal ID for this linter. * * This ID is assigned automatically by the @{class:ArcanistLintEngine}. * * @param string Unique linter ID. * @return this * @task state */ final public function setLinterID($id) { $this->id = $id; return $this; } /** * Get the internal ID for this linter. * * Retrieves an internal linter ID managed by the @{class:ArcanistLintEngine}. * This ID is a unique scalar which distinguishes linters in a list. * * @return string Unique linter ID. * @task state */ final public function getLinterID() { return $this->id; } /* -( Executing Linters )-------------------------------------------------- */ /** * Hook called before a list of paths are linted. * * Parallelizable linters can start multiple requests in parallel here, * to improve performance. They can implement @{method:didLintPaths} to * collect results. * * Linters which are not parallelizable should normally ignore this callback * and implement @{method:lintPath} instead. * * @param list A list of paths to be linted * @return void * @task exec */ public function willLintPaths(array $paths) { return; } /** * Hook called for each path to be linted. * * Linters which are not parallelizable can do work here. * * Linters which are parallelizable may want to ignore this callback and * implement @{method:willLintPaths} and @{method:didLintPaths} instead. * * @param string Path to lint. * @return void * @task exec */ public function lintPath($path) { return; } /** * Hook called after a list of paths are linted. * * Parallelizable linters can collect results here. * * Linters which are not paralleizable should normally ignore this callback * and implement @{method:lintPath} instead. * * @param list A list of paths which were linted. * @return void * @task exec */ public function didLintPaths(array $paths) { return; } /** * Obsolete hook which was invoked before a path was linted. * * WARNING: This is an obsolete hook which is not called. If you maintain * a linter which relies on it, update to use @{method:lintPath} instead. * * @task exec */ final public function willLintPath($path) { // TODO: Remove this method after some time. In the meantime, the "final" // will fatal subclasses which implement this hook and point at the API // change so maintainers get fewer surprises. throw new PhutilMethodNotImplementedException(); } /** * Obsolete hook which was invoked after linters ran. * * WARNING: This is an obsolete hook which is not called. If you maintain * a linter which relies on it, update to use @{method:didLintPaths} instead. * * @return void * @task exec */ final public function didRunLinters() { // TODO: Remove this method after some time. In the meantime, the "final" // will fatal subclasses which implement this hook and point at the API // change so maintainers get fewer surprises. throw new PhutilMethodNotImplementedException(); } public function getLinterPriority() { return 1.0; } /** * TODO: This should be `final`. */ public function setCustomSeverityMap(array $map) { $this->customSeverityMap = $map; return $this; } final public function setCustomSeverityRules(array $rules) { $this->customSeverityRules = $rules; return $this; } final public function getProjectRoot() { $engine = $this->getEngine(); if (!$engine) { throw new Exception( pht( 'You must call %s before you can call %s.', 'setEngine()', __FUNCTION__.'()')); } $working_copy = $engine->getWorkingCopy(); if (!$working_copy) { return null; } return $working_copy->getProjectRoot(); } final public function getOtherLocation($offset, $path = null) { if ($path === null) { $path = $this->getActivePath(); } list($line, $char) = $this->getEngine()->getLineAndCharFromOffset( $path, $offset); return array( 'path' => $path, 'line' => $line + 1, 'char' => $char, ); } final public function stopAllLinters() { $this->stopAllLinters = true; return $this; } final public function didStopAllLinters() { return $this->stopAllLinters; } final public function addPath($path) { $this->paths[$path] = $path; $this->filteredPaths = null; return $this; } final public function setPaths(array $paths) { $this->paths = $paths; $this->filteredPaths = null; return $this; } /** * Filter out paths which this linter doesn't act on (for example, because * they are binaries and the linter doesn't apply to binaries). * * @param list * @return list */ final private function filterPaths(array $paths) { $engine = $this->getEngine(); $keep = array(); foreach ($paths as $path) { if (!$this->shouldLintDeletedFiles() && !$engine->pathExists($path)) { continue; } if (!$this->shouldLintDirectories() && $engine->isDirectory($path)) { continue; } if (!$this->shouldLintBinaryFiles() && $engine->isBinaryFile($path)) { continue; } if (!$this->shouldLintSymbolicLinks() && $engine->isSymbolicLink($path)) { continue; } $keep[] = $path; } return $keep; } final public function getPaths() { if ($this->filteredPaths === null) { $this->filteredPaths = $this->filterPaths(array_values($this->paths)); } return $this->filteredPaths; } final public function addData($path, $data) { $this->data[$path] = $data; return $this; } final protected function getData($path) { if (!array_key_exists($path, $this->data)) { $this->data[$path] = $this->getEngine()->loadData($path); } return $this->data[$path]; } public function getCacheVersion() { return 0; } final public function getLintMessageFullCode($short_code) { return $this->getLinterName().$short_code; } final public function getLintMessageSeverity($code) { $map = $this->customSeverityMap; if (isset($map[$code])) { return $map[$code]; } foreach ($this->customSeverityRules as $rule => $severity) { if (preg_match($rule, $code)) { return $severity; } } $map = $this->getLintSeverityMap(); if (isset($map[$code])) { return $map[$code]; } return $this->getDefaultMessageSeverity($code); } protected function getDefaultMessageSeverity($code) { return ArcanistLintSeverity::SEVERITY_ERROR; } final public function isMessageEnabled($code) { return ($this->getLintMessageSeverity($code) !== ArcanistLintSeverity::SEVERITY_DISABLED); } final public function getLintMessageName($code) { $map = $this->getLintNameMap(); if (isset($map[$code])) { return $map[$code]; } return pht('Unknown lint message!'); } final protected function addLintMessage(ArcanistLintMessage $message) { $root = $this->getProjectRoot(); $path = Filesystem::resolvePath($message->getPath(), $root); $message->setPath(Filesystem::readablePath($path, $root)); $this->messages[] = $message; return $message; } final public function getLintMessages() { return $this->messages; } final public function raiseLintAtLine( $line, $char, $code, $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); } final public function raiseLintAtPath($code, $desc) { return $this->raiseLintAtLine(null, null, $code, $desc, null, null); } final public 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 canRun() { return true; } abstract public function getLinterName(); public function getVersion() { return null; } final protected function isCodeEnabled($code) { $severity = $this->getLintMessageSeverity($code); return $this->getEngine()->isSeverityEnabled($severity); } public function getLintSeverityMap() { return array(); } public function getLintNameMap() { return array(); } public function getCacheGranularity() { return self::GRANULARITY_FILE; } /** * If this linter is selectable via `.arclint` configuration files, return * a short, human-readable name to identify it. For example, `"jshint"` or * `"pep8"`. * * If you do not implement this method, the linter will not be selectable * through `.arclint` files. */ public function getLinterConfigurationName() { return null; } public function getLinterConfigurationOptions() { if (!$this->canCustomizeLintSeverities()) { return array(); } return array( 'severity' => array( 'type' => 'optional map', 'help' => pht( 'Provide a map from lint codes to adjusted severity levels: error, '. 'warning, advice, autofix or disabled.'), ), 'severity.rules' => array( 'type' => 'optional map', 'help' => pht( 'Provide a map of regular expressions to severity levels. All '. 'matching codes have their severity adjusted.'), ), ); } public function setLinterConfigurationValue($key, $value) { $sev_map = array( 'error' => ArcanistLintSeverity::SEVERITY_ERROR, 'warning' => ArcanistLintSeverity::SEVERITY_WARNING, 'autofix' => ArcanistLintSeverity::SEVERITY_AUTOFIX, 'advice' => ArcanistLintSeverity::SEVERITY_ADVICE, 'disabled' => ArcanistLintSeverity::SEVERITY_DISABLED, ); switch ($key) { case 'severity': if (!$this->canCustomizeLintSeverities()) { break; } $custom = array(); foreach ($value as $code => $severity) { if (empty($sev_map[$severity])) { $valid = implode(', ', array_keys($sev_map)); throw new Exception( pht( 'Unknown lint severity "%s". Valid severities are: %s.', $severity, $valid)); } $code = $this->getLintCodeFromLinterConfigurationKey($code); $custom[$code] = $severity; } $this->setCustomSeverityMap($custom); return; case 'severity.rules': if (!$this->canCustomizeLintSeverities()) { break; } foreach ($value as $rule => $severity) { if (@preg_match($rule, '') === false) { throw new Exception( pht( 'Severity rule "%s" is not a valid regular expression.', $rule)); } if (empty($sev_map[$severity])) { $valid = implode(', ', array_keys($sev_map)); throw new Exception( pht( 'Unknown lint severity "%s". Valid severities are: %s.', $severity, $valid)); } } $this->setCustomSeverityRules($value); return; } throw new Exception(pht('Incomplete implementation: %s!', $key)); } protected function canCustomizeLintSeverities() { return true; } protected function shouldLintBinaryFiles() { return false; } protected function shouldLintDeletedFiles() { return false; } protected function shouldLintDirectories() { return false; } protected function shouldLintSymbolicLinks() { return false; } /** * Map a configuration lint code to an `arc` lint code. Primarily, this is * intended for validation, but can also be used to normalize case or * otherwise be more permissive in accepted inputs. * * If the code is not recognized, you should throw an exception. * * @param string Code specified in configuration. * @return string Normalized code to use in severity map. */ protected function getLintCodeFromLinterConfigurationKey($code) { return $code; } } diff --git a/src/lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php b/src/lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php index 7848bb13..a7d9ab18 100644 --- a/src/lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php +++ b/src/lint/linter/xhpast/ArcanistXHPASTLintNamingHook.php @@ -1,153 +1,153 @@ } /* -( Overriding Symbol Name Lint Messages )------------------------------- */ /** * Callback invoked for each symbol, which can override the default * determination of name validity or accept it by returning $default. The * symbol types are: xhp-class, class, interface, function, method, parameter, * constant, and member. * * For example, if you want to ban all symbols with "quack" in them and * otherwise accept all the defaults, except allow any naming convention for * methods with "duck" in them, you might implement the method like this: * * if (preg_match('/quack/i', $name)) { * return 'Symbol names containing "quack" are forbidden.'; * } * if ($type == 'method' && preg_match('/duck/i', $name)) { * return null; // Always accept. * } * return $default; * * @param string The symbol type. * @param string The symbol name. * @param string|null The default result from the main rule engine. * @return string|null Null to accept the name, or a message to reject it * with. You should return the default value if you don't * want to specifically provide an override. * @task override */ abstract public function lintSymbolName($type, $name, $default); /* -( Name Utilities )----------------------------------------------------- */ /** * Returns true if a symbol name is UpperCamelCase. * * @param string Symbol name. * @return bool True if the symbol is UpperCamelCase. * @task util */ public static function isUpperCamelCase($symbol) { return preg_match('/^[A-Z][A-Za-z0-9]*$/', $symbol); } /** * Returns true if a symbol name is lowerCamelCase. * * @param string Symbol name. * @return bool True if the symbol is lowerCamelCase. * @task util */ public static function isLowerCamelCase($symbol) { return preg_match('/^[a-z][A-Za-z0-9]*$/', $symbol); } /** * Returns true if a symbol name is UPPERCASE_WITH_UNDERSCORES. * * @param string Symbol name. * @return bool True if the symbol is UPPERCASE_WITH_UNDERSCORES. * @task util */ public static function isUppercaseWithUnderscores($symbol) { return preg_match('/^[A-Z0-9_]+$/', $symbol); } /** * Returns true if a symbol name is lowercase_with_underscores. * * @param string Symbol name. * @return bool True if the symbol is lowercase_with_underscores. * @task util */ public static function isLowercaseWithUnderscores($symbol) { return preg_match('/^[a-z0-9_]+$/', $symbol); } /** * Strip non-name components from PHP function symbols. Notably, this discards * the "__" magic-method signifier, to make a symbol appropriate for testing * with methods like @{method:isLowerCamelCase}. * * @param string Symbol name. * @return string Stripped symbol. * @task util */ public static function stripPHPFunction($symbol) { switch ($symbol) { case '__assign_concat': case '__call': case '__callStatic': case '__clone': case '__concat': case '__construct': case '__debugInfo': case '__destruct': case '__get': case '__invoke': case '__isset': case '__set': case '__set_state': case '__sleep': case '__toString': case '__unset': case '__wakeup': return preg_replace('/^__/', '', $symbol); default: return $symbol; } } /** * Strip non-name components from PHP variable symbols. Notably, this discards * the "$", to make a symbol appropriate for testing with methods like * @{method:isLowercaseWithUnderscores}. * * @param string Symbol name. * @return string Stripped symbol. * @task util */ public static function stripPHPVariable($symbol) { return preg_replace('/^\$/', '', $symbol); } } diff --git a/src/lint/linter/xhpast/ArcanistXHPASTLintSwitchHook.php b/src/lint/linter/xhpast/ArcanistXHPASTLintSwitchHook.php index 7f909bb7..114ef179 100644 --- a/src/lint/linter/xhpast/ArcanistXHPASTLintSwitchHook.php +++ b/src/lint/linter/xhpast/ArcanistXHPASTLintSwitchHook.php @@ -1,14 +1,14 @@ getConstant('ID'); if ($const === false) { throw new Exception( pht( '`%s` class `%s` must define an ID constant.', __CLASS__, get_class($this))); } if (!is_int($const)) { throw new Exception( pht( '`%s` class `%s` has an invalid ID constant. ID must be an integer.', __CLASS__, get_class($this))); } return $const; } abstract public function getLintName(); public function getLintSeverity() { return ArcanistLintSeverity::SEVERITY_ERROR; } public function getLinterConfigurationOptions() { return array(); } public function setLinterConfigurationValue($key, $value) {} abstract public function process(XHPASTNode $root); final public function setLinter(ArcanistXHPASTLinter $linter) { $this->linter = $linter; } /** * Statically evaluate a boolean value from an XHP tree. * * TODO: Improve this and move it to XHPAST proper? * * @param string The "semantic string" of a single value. * @return mixed `true` or `false` if the value could be evaluated * statically; `null` if static evaluation was not possible. */ protected function evaluateStaticBoolean($string) { switch (strtolower($string)) { case '0': case 'null': case 'false': return false; case '1': case 'true': return true; } return null; } protected function getConcreteVariableString(XHPASTNode $var) { $concrete = $var->getConcreteString(); // Strip off curly braces as in `$obj->{$property}`. $concrete = trim($concrete, '{}'); return $concrete; } // These methods are proxied to the @{class:ArcanistLinter}. final public function getActivePath() { return $this->linter->getActivePath(); } final public function getOtherLocation($offset, $path = null) { return $this->linter->getOtherLocation($offset, $path); } final protected function raiseLintAtNode( XHPASTNode $node, $desc, $replace = null) { return $this->linter->raiseLintAtNode( $node, $this->getLintID(), $desc, $replace); } final public function raiseLintAtOffset( $offset, $desc, $text = null, $replace = null) { return $this->linter->raiseLintAtOffset( $offset, $this->getLintID(), $desc, $text, $replace); } final protected function raiseLintAtToken( XHPASTToken $token, $desc, $replace = null) { return $this->linter->raiseLintAtToken( $token, $this->getLintID(), $desc, $replace); } /* -( Utility )------------------------------------------------------------ */ /** * Retrieve all calls to some specified function(s). * * Returns all descendant nodes which represent a function call to one of the * specified functions. * * @param XHPASTNode Root node. * @param list Function names. * @return AASTNodeList */ protected function getFunctionCalls(XHPASTNode $root, array $function_names) { $calls = $root->selectDescendantsOfType('n_FUNCTION_CALL'); $nodes = array(); foreach ($calls as $call) { $node = $call->getChildByIndex(0); $name = strtolower($node->getConcreteString()); if (in_array($name, $function_names)) { $nodes[] = $call; } } return AASTNodeList::newFromTreeAndNodes($root->getTree(), $nodes); } public function getSuperGlobalNames() { return array( '$GLOBALS', '$_SERVER', '$_GET', '$_POST', '$_FILES', '$_COOKIE', '$_SESSION', '$_REQUEST', '$_ENV', ); } } diff --git a/src/lint/renderer/ArcanistLintRenderer.php b/src/lint/renderer/ArcanistLintRenderer.php index 0eced5bd..f153967d 100644 --- a/src/lint/renderer/ArcanistLintRenderer.php +++ b/src/lint/renderer/ArcanistLintRenderer.php @@ -1,19 +1,19 @@ api = $api; return $this; } private function tokenizeBaseCommitSpecification($raw_spec) { if (!$raw_spec) { return array(); } $spec = preg_split('/\s*,\s*/', $raw_spec); $spec = array_filter($spec); foreach ($spec as $rule) { if (strpos($rule, ':') === false) { throw new ArcanistUsageException( pht( "Rule '%s' is invalid, it must have a type and name like '%s'.", $rule, 'arc:upstream')); } } return $spec; } private function log($message) { if ($this->verbose) { fwrite(STDERR, $message."\n"); } } public function resolveBaseCommit(array $specs) { $specs += array( 'runtime' => '', 'local' => '', 'project' => '', 'user' => '', 'system' => '', ); foreach ($specs as $source => $spec) { $specs[$source] = self::tokenizeBaseCommitSpecification($spec); } $this->try = array( 'runtime', 'local', 'project', 'user', 'system', ); while ($this->try) { $source = head($this->try); if (!idx($specs, $source)) { $this->log(pht("No rules left from source '%s'.", $source)); array_shift($this->try); continue; } $this->log(pht("Trying rules from source '%s'.", $source)); $rules = &$specs[$source]; while ($rule = array_shift($rules)) { $this->log(pht("Trying rule '%s'.", $rule)); $commit = $this->resolveRule($rule, $source); if ($commit === false) { // If a rule returns false, it means to go to the next ruleset. break; } else if ($commit !== null) { $this->log(pht( "Resolved commit '%s' from rule '%s'.", $commit, $rule)); return $commit; } } } return null; } /** * Handle resolving individual rules. */ private function resolveRule($rule, $source) { // NOTE: Returning `null` from this method means "no match". // Returning `false` from this method means "stop current ruleset". list($type, $name) = explode(':', $rule, 2); switch ($type) { case 'literal': return $name; case 'git': case 'hg': return $this->api->resolveBaseCommitRule($rule, $source); case 'arc': return $this->resolveArcRule($rule, $name, $source); default: throw new ArcanistUsageException( pht( "Base commit rule '%s' (from source '%s') ". "is not a recognized rule.", $rule, $source)); } } /** * Handle resolving "arc:*" rules. */ private function resolveArcRule($rule, $name, $source) { $name = $this->updateLegacyRuleName($name); switch ($name) { case 'verbose': $this->verbose = true; $this->log(pht('Enabled verbose mode.')); break; case 'prompt': $reason = pht('it is what you typed when prompted.'); $this->api->setBaseCommitExplanation($reason); return phutil_console_prompt(pht('Against which commit?')); case 'local': case 'user': case 'project': case 'runtime': case 'system': // Push the other source on top of the list. array_unshift($this->try, $name); $this->log(pht("Switching to source '%s'.", $name)); return false; case 'yield': // Cycle this source to the end of the list. $this->try[] = array_shift($this->try); $this->log(pht("Yielding processing of rules from '%s'.", $source)); return false; case 'halt': // Dump the whole stack. $this->try = array(); $this->log(pht('Halting all rule processing.')); return false; case 'skip': return null; case 'empty': case 'upstream': case 'outgoing': case 'bookmark': case 'amended': case 'this': return $this->api->resolveBaseCommitRule($rule, $source); default: $matches = null; if (preg_match('/^exec\((.*)\)$/', $name, $matches)) { $root = $this->api->getWorkingCopyIdentity()->getProjectRoot(); $future = new ExecFuture('%C', $matches[1]); $future->setCWD($root); list($err, $stdout) = $future->resolve(); if (!$err) { return trim($stdout); } else { return null; } } else if (preg_match('/^nodiff\((.*)\)$/', $name, $matches)) { return $this->api->resolveBaseCommitRule($rule, $source); } throw new ArcanistUsageException( pht( "Base commit rule '%s' (from source '%s') ". "is not a recognized rule.", $rule, $source)); } } private function updateLegacyRuleName($name) { $updated = array( 'global' => 'user', 'args' => 'runtime', ); $new_name = idx($updated, $name); if ($new_name) { $this->log(pht("Translating legacy name '%s' to '%s'", $name, $new_name)); return $new_name; } return $name; } } diff --git a/src/parser/ArcanistBundle.php b/src/parser/ArcanistBundle.php index 14ebbe05..a4d85581 100644 --- a/src/parser/ArcanistBundle.php +++ b/src/parser/ArcanistBundle.php @@ -1,860 +1,860 @@ authorEmail = $author_email; return $this; } public function getAuthorEmail() { return $this->authorEmail; } public function setAuthorName($author_name) { $this->authorName = $author_name; return $this; } public function getAuthorName() { return $this->authorName; } public function getFullAuthor() { $author_name = $this->getAuthorName(); if ($author_name === null) { return null; } $author_email = $this->getAuthorEmail(); if ($author_email === null) { return null; } $full_author = sprintf('%s <%s>', $author_name, $author_email); // Because git is very picky about the author being in a valid format, // verify that we can parse it. $address = new PhutilEmailAddress($full_author); if (!$address->getDisplayName() || !$address->getAddress()) { return null; } return $full_author; } public function setConduit(ConduitClient $conduit) { $this->conduit = $conduit; return $this; } public function setBaseRevision($base_revision) { $this->baseRevision = $base_revision; return $this; } public function setEncoding($encoding) { $this->encoding = $encoding; return $this; } public function getEncoding() { return $this->encoding; } public function getBaseRevision() { return $this->baseRevision; } public function setRevisionID($revision_id) { $this->revisionID = $revision_id; return $this; } public function getRevisionID() { return $this->revisionID; } public static function newFromChanges(array $changes) { $obj = new ArcanistBundle(); $obj->changes = $changes; return $obj; } private function getEOL($patch_type) { // NOTE: Git always generates "\n" line endings, even under Windows, and // can not parse certain patches with "\r\n" line endings. SVN generates // patches with "\n" line endings on Mac or Linux and "\r\n" line endings // on Windows. (This EOL style is used only for patch metadata lines, not // for the actual patch content.) // (On Windows, Mercurial generates \n newlines for `--git` diffs, as it // must, but also \n newlines for unified diffs. We never need to deal with // these as we use Git format for Mercurial, so this case is currently // ignored.) switch ($patch_type) { case 'git': return "\n"; case 'unified': return phutil_is_windows() ? "\r\n" : "\n"; default: throw new Exception( pht("Unknown patch type '%s'!", $patch_type)); } } public static function newFromArcBundle($path) { $path = Filesystem::resolvePath($path); $future = new ExecFuture( 'tar tfO %s', $path); list($stdout, $file_list) = $future->resolvex(); $file_list = explode("\n", trim($file_list)); if (in_array('meta.json', $file_list)) { $future = new ExecFuture( 'tar xfO %s meta.json', $path); $meta_info = $future->resolveJSON(); $version = idx($meta_info, 'version', 0); $base_revision = idx($meta_info, 'baseRevision'); $revision_id = idx($meta_info, 'revisionID'); $encoding = idx($meta_info, 'encoding'); $author_name = idx($meta_info, 'authorName'); $author_email = idx($meta_info, 'authorEmail'); } else { // this arc bundle was probably made before we started storing meta info $version = 0; $base_revision = null; $revision_id = null; $encoding = null; $author = null; } $future = new ExecFuture( 'tar xfO %s changes.json', $path); $changes = $future->resolveJSON(); foreach ($changes as $change_key => $change) { foreach ($change['hunks'] as $key => $hunk) { list($hunk_data) = execx('tar xfO %s hunks/%s', $path, $hunk['corpus']); $changes[$change_key]['hunks'][$key]['corpus'] = $hunk_data; } } foreach ($changes as $change_key => $change) { $changes[$change_key] = ArcanistDiffChange::newFromDictionary($change); } $obj = new ArcanistBundle(); $obj->changes = $changes; $obj->diskPath = $path; $obj->setBaseRevision($base_revision); $obj->setRevisionID($revision_id); $obj->setEncoding($encoding); return $obj; } public static function newFromDiff($data) { $obj = new ArcanistBundle(); $parser = new ArcanistDiffParser(); $obj->changes = $parser->parseDiff($data); return $obj; } private function __construct() {} public function writeToDisk($path) { $changes = $this->getChanges(); $change_list = array(); foreach ($changes as $change) { $change_list[] = $change->toDictionary(); } $hunks = array(); foreach ($change_list as $change_key => $change) { foreach ($change['hunks'] as $key => $hunk) { $hunks[] = $hunk['corpus']; $change_list[$change_key]['hunks'][$key]['corpus'] = count($hunks) - 1; } } $blobs = array(); foreach ($change_list as $change) { if (!empty($change['metadata']['old:binary-phid'])) { $blobs[$change['metadata']['old:binary-phid']] = null; } if (!empty($change['metadata']['new:binary-phid'])) { $blobs[$change['metadata']['new:binary-phid']] = null; } } foreach ($blobs as $phid => $null) { $blobs[$phid] = $this->getBlob($phid); } $meta_info = array( 'version' => 5, 'baseRevision' => $this->getBaseRevision(), 'revisionID' => $this->getRevisionID(), 'encoding' => $this->getEncoding(), 'authorName' => $this->getAuthorName(), 'authorEmail' => $this->getAuthorEmail(), ); $dir = Filesystem::createTemporaryDirectory(); Filesystem::createDirectory($dir.'/hunks'); Filesystem::createDirectory($dir.'/blobs'); Filesystem::writeFile($dir.'/changes.json', json_encode($change_list)); Filesystem::writeFile($dir.'/meta.json', json_encode($meta_info)); foreach ($hunks as $key => $hunk) { Filesystem::writeFile($dir.'/hunks/'.$key, $hunk); } foreach ($blobs as $key => $blob) { Filesystem::writeFile($dir.'/blobs/'.$key, $blob); } execx( '(cd %s; tar -czf %s *)', $dir, Filesystem::resolvePath($path)); Filesystem::remove($dir); } public function toUnifiedDiff() { $eol = $this->getEOL('unified'); $result = array(); $changes = $this->getChanges(); foreach ($changes as $change) { $hunk_changes = $this->buildHunkChanges($change->getHunks(), $eol); if (!$hunk_changes) { continue; } $old_path = $this->getOldPath($change); $cur_path = $this->getCurrentPath($change); $index_path = $cur_path; if ($index_path === null) { $index_path = $old_path; } $result[] = 'Index: '.$index_path; $result[] = $eol; $result[] = str_repeat('=', 67); $result[] = $eol; if ($old_path === null) { $old_path = '/dev/null'; } if ($cur_path === null) { $cur_path = '/dev/null'; } // When the diff is used by `patch`, `patch` ignores what is listed as the // current path and just makes changes to the file at the old path (unless // the current path is '/dev/null'. // If the old path and the current path aren't the same (and neither is // /dev/null), this indicates the file was moved or copied. By listing // both paths as the new file, `patch` will apply the diff to the new // file. if ($cur_path !== '/dev/null' && $old_path !== '/dev/null') { $old_path = $cur_path; } $result[] = '--- '.$old_path.$eol; $result[] = '+++ '.$cur_path.$eol; $result[] = $hunk_changes; } if (!$result) { return ''; } $diff = implode('', $result); return $this->convertNonUTF8Diff($diff); } public function toGitPatch() { $eol = $this->getEOL('git'); $result = array(); $changes = $this->getChanges(); $binary_sources = array(); foreach ($changes as $change) { if (!$this->isGitBinaryChange($change)) { continue; } $type = $change->getType(); if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY || $type == ArcanistDiffChangeType::TYPE_COPY_AWAY || $type == ArcanistDiffChangeType::TYPE_MULTICOPY) { foreach ($change->getAwayPaths() as $path) { $binary_sources[$path] = $change; } } } foreach (array_keys($changes) as $multicopy_key) { $multicopy_change = $changes[$multicopy_key]; $type = $multicopy_change->getType(); if ($type != ArcanistDiffChangeType::TYPE_MULTICOPY) { continue; } // Decompose MULTICOPY into one MOVE_HERE and several COPY_HERE because // we need more information than we have in order to build a delete patch // and represent it as a bunch of COPY_HERE plus a delete. For details, // see T419. // Basically, MULTICOPY means there are 2 or more corresponding COPY_HERE // changes, so find one of them arbitrarily and turn it into a MOVE_HERE. // TODO: We might be able to do this more cleanly after T230 is resolved. $decompose_okay = false; foreach ($changes as $change_key => $change) { if ($change->getType() != ArcanistDiffChangeType::TYPE_COPY_HERE) { continue; } if ($change->getOldPath() != $multicopy_change->getCurrentPath()) { continue; } $decompose_okay = true; $change = clone $change; $change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE); $changes[$change_key] = $change; // The multicopy is now fully represented by MOVE_HERE plus one or more // COPY_HERE, so throw it away. unset($changes[$multicopy_key]); break; } if (!$decompose_okay) { throw new Exception( pht( 'Failed to decompose multicopy changeset in '. 'order to generate diff.')); } } foreach ($changes as $change) { $type = $change->getType(); $file_type = $change->getFileType(); if ($file_type == ArcanistDiffChangeType::FILE_DIRECTORY) { // TODO: We should raise a FYI about this, so the user is aware // that we omitted it, if the directory is empty or has permissions // which git can't represent. // Git doesn't support empty directories, so we simply ignore them. If // the directory is nonempty, 'git apply' will create it when processing // the changesets for files inside it. continue; } if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { // Git will apply this in the corresponding MOVE_HERE. continue; } $old_mode = idx($change->getOldProperties(), 'unix:filemode', '100644'); $new_mode = idx($change->getNewProperties(), 'unix:filemode', '100644'); $is_binary = $this->isGitBinaryChange($change); if ($is_binary) { $old_binary = idx($binary_sources, $this->getCurrentPath($change)); $change_body = $this->buildBinaryChange($change, $old_binary); } else { $change_body = $this->buildHunkChanges($change->getHunks(), $eol); } if ($type == ArcanistDiffChangeType::TYPE_COPY_AWAY) { // TODO: This is only relevant when patching old Differential diffs // which were created prior to arc pruning TYPE_COPY_AWAY for files // with no modifications. if (!strlen($change_body) && ($old_mode == $new_mode)) { continue; } } $old_path = $this->getOldPath($change); $cur_path = $this->getCurrentPath($change); if ($old_path === null) { $old_index = 'a/'.$cur_path; $old_target = '/dev/null'; } else { $old_index = 'a/'.$old_path; $old_target = 'a/'.$old_path; } if ($cur_path === null) { $cur_index = 'b/'.$old_path; $cur_target = '/dev/null'; } else { $cur_index = 'b/'.$cur_path; $cur_target = 'b/'.$cur_path; } $result[] = "diff --git {$old_index} {$cur_index}".$eol; if ($type == ArcanistDiffChangeType::TYPE_ADD) { $result[] = "new file mode {$new_mode}".$eol; } if ($type == ArcanistDiffChangeType::TYPE_COPY_HERE || $type == ArcanistDiffChangeType::TYPE_MOVE_HERE || $type == ArcanistDiffChangeType::TYPE_COPY_AWAY || $type == ArcanistDiffChangeType::TYPE_CHANGE) { if ($old_mode !== $new_mode) { $result[] = "old mode {$old_mode}".$eol; $result[] = "new mode {$new_mode}".$eol; } } if ($type == ArcanistDiffChangeType::TYPE_COPY_HERE) { $result[] = "copy from {$old_path}".$eol; $result[] = "copy to {$cur_path}".$eol; } else if ($type == ArcanistDiffChangeType::TYPE_MOVE_HERE) { $result[] = "rename from {$old_path}".$eol; $result[] = "rename to {$cur_path}".$eol; } else if ($type == ArcanistDiffChangeType::TYPE_DELETE || $type == ArcanistDiffChangeType::TYPE_MULTICOPY) { $old_mode = idx($change->getOldProperties(), 'unix:filemode'); if ($old_mode) { $result[] = "deleted file mode {$old_mode}".$eol; } } if ($change_body) { if (!$is_binary) { $result[] = "--- {$old_target}".$eol; $result[] = "+++ {$cur_target}".$eol; } $result[] = $change_body; } } $diff = implode('', $result).$eol; return $this->convertNonUTF8Diff($diff); } private function isGitBinaryChange(ArcanistDiffChange $change) { $file_type = $change->getFileType(); return ($file_type == ArcanistDiffChangeType::FILE_BINARY || $file_type == ArcanistDiffChangeType::FILE_IMAGE); } private function convertNonUTF8Diff($diff) { if ($this->encoding) { $diff = phutil_utf8_convert($diff, $this->encoding, 'UTF-8'); } return $diff; } public function getChanges() { return $this->changes; } private function breakHunkIntoSmallHunks(ArcanistDiffHunk $base_hunk) { $context = 3; $results = array(); $lines = phutil_split_lines($base_hunk->getCorpus()); $n = count($lines); $old_offset = $base_hunk->getOldOffset(); $new_offset = $base_hunk->getNewOffset(); $ii = 0; $jj = 0; while ($ii < $n) { // Skip lines until we find the next line with changes. Note: this skips // both ' ' (no changes) and '\' (no newline at end of file) lines. If we // don't skip the latter, we may incorrectly generate a terminal hunk // that has no actual change information when a file doesn't have a // terminal newline and not changed near the end of the file. 'patch' will // fail to apply the diff if we generate a hunk that does not actually // contain changes. for ($jj = $ii; $jj < $n; ++$jj) { $char = $lines[$jj][0]; if ($char == '-' || $char == '+') { break; } } if ($jj >= $n) { break; } $hunk_start = max($jj - $context, 0); // NOTE: There are two tricky considerations here. // We can not generate a patch with overlapping hunks, or 'git apply' // rejects it after 1.7.3.4. // We can not generate a patch with too much trailing context, or // 'patch' rejects it. // So we need to ensure that we generate disjoint hunks, but don't // generate any hunks with too much context. $old_lines = 0; $new_lines = 0; $hunk_adjust = 0; $last_change = $jj; $break_here = null; for (; $jj < $n; ++$jj) { if ($lines[$jj][0] == ' ') { if ($jj - $last_change > $context) { if ($break_here === null) { // We haven't seen a change in $context lines, so this is a // potential place to break the hunk. However, we need to keep // looking in case there is another change fewer than $context // lines away, in which case we have to merge the hunks. $break_here = $jj; } } if ($jj - $last_change > (($context + 1) * 2)) { // We definitely aren't going to merge this with the next hunk, so // break out of the loop. We'll end the hunk at $break_here. break; } } else { $break_here = null; $last_change = $jj; if ($lines[$jj][0] == '\\') { // When we have a "\ No newline at end of file" line, it does not // contribute to either hunk length. ++$hunk_adjust; } else if ($lines[$jj][0] == '-') { ++$old_lines; } else if ($lines[$jj][0] == '+') { ++$new_lines; } } } if ($break_here !== null) { $jj = $break_here; } $hunk_length = min($jj, $n) - $hunk_start; $count_length = ($hunk_length - $hunk_adjust); $hunk = new ArcanistDiffHunk(); $hunk->setOldOffset($old_offset + $hunk_start - $ii); $hunk->setNewOffset($new_offset + $hunk_start - $ii); $hunk->setOldLength($count_length - $new_lines); $hunk->setNewLength($count_length - $old_lines); $corpus = array_slice($lines, $hunk_start, $hunk_length); $corpus = implode('', $corpus); $hunk->setCorpus($corpus); $results[] = $hunk; $old_offset += ($jj - $ii) - $new_lines; $new_offset += ($jj - $ii) - $old_lines; $ii = $jj; } return $results; } private function getOldPath(ArcanistDiffChange $change) { $old_path = $change->getOldPath(); $type = $change->getType(); if (!strlen($old_path) || $type == ArcanistDiffChangeType::TYPE_ADD) { $old_path = null; } return $old_path; } private function getCurrentPath(ArcanistDiffChange $change) { $cur_path = $change->getCurrentPath(); $type = $change->getType(); if (!strlen($cur_path) || $type == ArcanistDiffChangeType::TYPE_DELETE || $type == ArcanistDiffChangeType::TYPE_MULTICOPY) { $cur_path = null; } return $cur_path; } private function buildHunkChanges(array $hunks, $eol) { assert_instances_of($hunks, 'ArcanistDiffHunk'); $result = array(); foreach ($hunks as $hunk) { $small_hunks = $this->breakHunkIntoSmallHunks($hunk); foreach ($small_hunks as $small_hunk) { $o_off = $small_hunk->getOldOffset(); $o_len = $small_hunk->getOldLength(); $n_off = $small_hunk->getNewOffset(); $n_len = $small_hunk->getNewLength(); $corpus = $small_hunk->getCorpus(); // NOTE: If the length is 1 it can be omitted. Since git does this, // we also do it so that "arc export --git" diffs are as similar to // real git diffs as possible, which helps debug issues. if ($o_len == 1) { $o_head = "{$o_off}"; } else { $o_head = "{$o_off},{$o_len}"; } if ($n_len == 1) { $n_head = "{$n_off}"; } else { $n_head = "{$n_off},{$n_len}"; } $result[] = "@@ -{$o_head} +{$n_head} @@".$eol; $result[] = $corpus; $last = substr($corpus, -1); if ($last !== false && $last != "\r" && $last != "\n") { $result[] = $eol; } } } return implode('', $result); } public function setLoadFileDataCallback($callback) { $this->loadFileDataCallback = $callback; return $this; } private function getBlob($phid, $name = null) { if ($this->loadFileDataCallback) { return call_user_func($this->loadFileDataCallback, $phid); } if ($this->diskPath) { list($blob_data) = execx('tar xfO %s blobs/%s', $this->diskPath, $phid); return $blob_data; } $console = PhutilConsole::getConsole(); if ($this->conduit) { if ($name) { $console->writeErr( "%s\n", pht("Downloading binary data for '%s'...", $name)); } else { $console->writeErr("%s\n", pht('Downloading binary data...')); } $data_base64 = $this->conduit->callMethodSynchronous( 'file.download', array( 'phid' => $phid, )); return base64_decode($data_base64); } throw new Exception(pht("Nowhere to load blob '%s' from!", $phid)); } private function buildBinaryChange(ArcanistDiffChange $change, $old_binary) { $eol = $this->getEOL('git'); // In Git, when we write out a binary file move or copy, we need the // original binary for the source and the current binary for the // destination. if ($old_binary) { if ($old_binary->getOriginalFileData() !== null) { $old_data = $old_binary->getOriginalFileData(); $old_phid = null; } else { $old_data = null; $old_phid = $old_binary->getMetadata('old:binary-phid'); } } else { $old_data = $change->getOriginalFileData(); $old_phid = $change->getMetadata('old:binary-phid'); } if ($old_data === null && $old_phid) { $name = basename($change->getOldPath()); $old_data = $this->getBlob($old_phid, $name); } $old_length = strlen($old_data); if ($old_data === null) { $old_data = ''; $old_sha1 = str_repeat('0', 40); } else { $old_sha1 = sha1("blob {$old_length}\0{$old_data}"); } $new_phid = $change->getMetadata('new:binary-phid'); $new_data = null; if ($change->getCurrentFileData() !== null) { $new_data = $change->getCurrentFileData(); } else if ($new_phid) { $name = basename($change->getCurrentPath()); $new_data = $this->getBlob($new_phid, $name); } $new_length = strlen($new_data); if ($new_data === null) { $new_data = ''; $new_sha1 = str_repeat('0', 40); } else { $new_sha1 = sha1("blob {$new_length}\0{$new_data}"); } $content = array(); $content[] = "index {$old_sha1}..{$new_sha1}".$eol; $content[] = 'GIT binary patch'.$eol; $content[] = "literal {$new_length}".$eol; $content[] = $this->emitBinaryDiffBody($new_data).$eol; $content[] = "literal {$old_length}".$eol; $content[] = $this->emitBinaryDiffBody($old_data).$eol; return implode('', $content); } private function emitBinaryDiffBody($data) { $eol = $this->getEOL('git'); if (!function_exists('gzcompress')) { throw new Exception( pht( 'This patch has binary data. The PHP zlib extension is required to '. 'apply patches with binary data to git. Install the PHP zlib '. 'extension to continue.')); } // See emit_binary_diff_body() in diff.c for git's implementation. $buf = ''; $deflated = gzcompress($data); $lines = str_split($deflated, 52); foreach ($lines as $line) { $len = strlen($line); // The first character encodes the line length. if ($len <= 26) { $buf .= chr($len + ord('A') - 1); } else { $buf .= chr($len - 26 + ord('a') - 1); } $buf .= self::encodeBase85($line); $buf .= $eol; } return $buf; } public static function encodeBase85($data) { // This is implemented awkwardly in order to closely mirror git's // implementation in base85.c // It is also implemented awkwardly to work correctly on 32-bit machines. // Broadly, this algorithm converts the binary input to printable output // by transforming each 4 binary bytes of input to 5 printable bytes of // output, one piece at a time. // // To do this, we convert the 4 bytes into a 32-bit integer, then use // modulus and division by 85 to pick out printable bytes (85^5 is slightly // larger than 2^32). In C, this algorithm is fairly easy to implement // because the accumulator can be made unsigned. // // In PHP, there are no unsigned integers, so values larger than 2^31 break // on 32-bit systems under modulus: // // $ php -r 'print (1 << 31) % 13;' # On a 32-bit machine. // -11 // // However, PHP's float type is an IEEE 754 64-bit double precision float, // so we can safely store integers up to around 2^53 without loss of // precision. To work around the lack of an unsigned type, we just use a // double and perform the modulus with fmod(). // // (Since PHP overflows integer operations into floats, we don't need much // additional casting.) static $map = array( '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '!', '#', '$', '%', '&', '(', ')', '*', '+', '-', ';', '<', '=', '>', '?', '@', '^', '_', '`', '{', '|', '}', '~', ); $buf = ''; $pos = 0; $bytes = strlen($data); while ($bytes) { $accum = 0; for ($count = 24; $count >= 0; $count -= 8) { $val = ord($data[$pos++]); $val = $val * (1 << $count); $accum = $accum + $val; if (--$bytes == 0) { break; } } $slice = ''; for ($count = 4; $count >= 0; $count--) { $val = (int)fmod($accum, 85.0); $accum = floor($accum / 85.0); $slice .= $map[$val]; } $buf .= strrev($slice); } return $buf; } } diff --git a/src/parser/ArcanistCommentRemover.php b/src/parser/ArcanistCommentRemover.php index 705f7a20..dad113c3 100644 --- a/src/parser/ArcanistCommentRemover.php +++ b/src/parser/ArcanistCommentRemover.php @@ -1,28 +1,28 @@ $line) { if (!strlen($line)) { unset($lines[$key]); continue; } if ($line[0] == '#') { unset($lines[$key]); continue; } break; } $lines = array_reverse($lines); return implode("\n", $lines)."\n"; } } diff --git a/src/parser/ArcanistDiffParser.php b/src/parser/ArcanistDiffParser.php index 7966461f..711edb63 100644 --- a/src/parser/ArcanistDiffParser.php +++ b/src/parser/ArcanistDiffParser.php @@ -1,1443 +1,1443 @@ repositoryAPI = $repository_api; return $this; } public function setDetectBinaryFiles($detect) { $this->detectBinaryFiles = $detect; return $this; } public function setTryEncoding($encoding) { $this->tryEncoding = $encoding; return $this; } public function forcePath($path) { $this->forcePath = $path; return $this; } public function setChanges(array $changes) { assert_instances_of($changes, 'ArcanistDiffChange'); $this->changes = mpull($changes, null, 'getCurrentPath'); return $this; } public function parseSubversionDiff(ArcanistSubversionAPI $api, $paths) { $this->setRepositoryAPI($api); $diffs = array(); foreach ($paths as $path => $status) { if ($status & ArcanistRepositoryAPI::FLAG_UNTRACKED || $status & ArcanistRepositoryAPI::FLAG_CONFLICT || $status & ArcanistRepositoryAPI::FLAG_MISSING) { unset($paths[$path]); } } $root = null; $from = array(); foreach ($paths as $path => $status) { $change = $this->buildChange($path); if ($status & ArcanistRepositoryAPI::FLAG_ADDED) { $change->setType(ArcanistDiffChangeType::TYPE_ADD); } else if ($status & ArcanistRepositoryAPI::FLAG_DELETED) { $change->setType(ArcanistDiffChangeType::TYPE_DELETE); } else { $change->setType(ArcanistDiffChangeType::TYPE_CHANGE); } $is_dir = is_dir($api->getPath($path)); if ($is_dir) { $change->setFileType(ArcanistDiffChangeType::FILE_DIRECTORY); // We have to go hit the diff even for directories because they may // have property changes or moves, etc. } $is_link = is_link($api->getPath($path)); if ($is_link) { $change->setFileType(ArcanistDiffChangeType::FILE_SYMLINK); } $diff = $api->getRawDiffText($path); if ($diff) { $this->parseDiff($diff); } $info = $api->getSVNInfo($path); if (idx($info, 'Copied From URL')) { if (!$root) { $rinfo = $api->getSVNInfo('.'); $root = $rinfo['URL'].'/'; } $cpath = $info['Copied From URL']; $root_len = strlen($root); if (!strncmp($cpath, $root, $root_len)) { $cpath = substr($cpath, $root_len); // The user can "svn cp /path/to/file@12345 x", which pulls a file out // of version history at a specific revision. If we just use the path, // we'll collide with possible changes to that path in the working // copy below. In particular, "svn cp"-ing a path which no longer // exists somewhere in the working copy and then adding that path // gets us to the "origin change type" branches below with a // TYPE_ADD state on the path. To avoid this, append the origin // revision to the path so we'll necessarily generate a new change. // TODO: In theory, you could have an '@' in your path and this could // cause a collision, e.g. two files named 'f' and 'f@12345'. This is // at least somewhat the user's fault, though. if ($info['Copied From Rev']) { if ($info['Copied From Rev'] != $info['Revision']) { $cpath .= '@'.$info['Copied From Rev']; } } $change->setOldPath($cpath); $from[$path] = $cpath; } } $type = $change->getType(); if (($type === ArcanistDiffChangeType::TYPE_MOVE_AWAY || $type === ArcanistDiffChangeType::TYPE_DELETE) && idx($info, 'Node Kind') === 'directory') { $change->setFileType(ArcanistDiffChangeType::FILE_DIRECTORY); } } foreach ($paths as $path => $status) { $change = $this->buildChange($path); if (empty($from[$path])) { continue; } if (empty($this->changes[$from[$path]])) { if ($change->getType() == ArcanistDiffChangeType::TYPE_COPY_HERE) { // If the origin path wasn't changed (or isn't included in this diff) // and we only copied it, don't generate a changeset for it. This // keeps us out of trouble when we go to 'arc commit' and need to // figure out which files should be included in the commit list. continue; } } $origin = $this->buildChange($from[$path]); $origin->addAwayPath($change->getCurrentPath()); $type = $origin->getType(); switch ($type) { case ArcanistDiffChangeType::TYPE_MULTICOPY: case ArcanistDiffChangeType::TYPE_COPY_AWAY: // "Add" is possible if you do some bizarre tricks with svn:ignore and // "svn copy"'ing URLs straight from the repository; you can end up with // a file that is a copy of itself. See T271. case ArcanistDiffChangeType::TYPE_ADD: break; case ArcanistDiffChangeType::TYPE_DELETE: $origin->setType(ArcanistDiffChangeType::TYPE_MOVE_AWAY); break; case ArcanistDiffChangeType::TYPE_MOVE_AWAY: $origin->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); break; case ArcanistDiffChangeType::TYPE_CHANGE: $origin->setType(ArcanistDiffChangeType::TYPE_COPY_AWAY); break; default: throw new Exception(pht('Bad origin state %s.', $type)); } $type = $origin->getType(); switch ($type) { case ArcanistDiffChangeType::TYPE_MULTICOPY: case ArcanistDiffChangeType::TYPE_MOVE_AWAY: $change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE); break; case ArcanistDiffChangeType::TYPE_ADD: case ArcanistDiffChangeType::TYPE_COPY_AWAY: $change->setType(ArcanistDiffChangeType::TYPE_COPY_HERE); break; default: throw new Exception(pht('Bad origin state %s.', $type)); } } return $this->changes; } public function parseDiff($diff) { if (!strlen(trim($diff))) { throw new Exception(pht("Can't parse an empty diff!")); } // Detect `git-format-patch`, by looking for a "---" line somewhere in // the file and then a footer with Git version number, which looks like // this: // // -- // 1.8.4.2 // // Note that `git-format-patch` adds a space after the "--", but we don't // require it when detecting patches, as trailing whitespace can easily be // lost in transit. $detect_patch = '/^---$.*^-- ?[\s\d.]+\z/ms'; $message = null; if (preg_match($detect_patch, $diff)) { list($message, $diff) = $this->stripGitFormatPatch($diff); } $this->didStartParse($diff); // Strip off header comments. While `patch` allows comments anywhere in the // file, `git apply` is more strict. We get these comments in `hg export` // diffs, and Eclipse can also produce them. $line = $this->getLineTrimmed(); while (preg_match('/^#/', $line)) { $line = $this->nextLine(); } if (strlen($message)) { // If we found a message during pre-parse steps, add it to the resulting // changes here. $change = $this->buildChange(null) ->setType(ArcanistDiffChangeType::TYPE_MESSAGE) ->setMetadata('message', $message); } do { $patterns = array( // This is a normal SVN text change, probably from "svn diff". '(?PIndex): (?P.+)', // This is an SVN text change, probably from "svnlook diff". '(?PModified|Added|Deleted|Copied): (?P.+)', // This is an SVN property change, probably from "svn diff". '(?PProperty changes on): (?P.+)', // This is a git commit message, probably from "git show". '(?Pcommit) (?P[a-f0-9]+)(?: \(.*\))?', // This is a git diff, probably from "git show" or "git diff". // Note that the filenames may appear quoted. '(?Pdiff --git) (?P.*)', // RCS Diff '(?Prcsdiff -u) (?P.*)', // This is a unified diff, probably from "diff -u" or synthetic diffing. '(?P---) (?P.+)\s+\d{4}-\d{2}-\d{2}.*', '(?PBinary files|Files) '. '(?P.+)\s+\d{4}-\d{2}-\d{2} and '. '(?P.+)\s+\d{4}-\d{2}-\d{2} differ.*', // This is a normal Mercurial text change, probably from "hg diff". It // may have two "-r" blocks if it came from "hg diff -r x:y". '(?Pdiff -r) (?P[a-f0-9]+) (?:-r [a-f0-9]+ )?(?P.+)', ); $line = $this->getLineTrimmed(); $match = null; $ok = $this->tryMatchHeader($patterns, $line, $match); $failed_parse = false; if (!$ok && $this->isFirstNonEmptyLine()) { // 'hg export' command creates so called "extended diff" that // contains some meta information and comment at the beginning // (isFirstNonEmptyLine() to check for beginning). Actual mercurial // code detects where comment ends and unified diff starts by // searching for "diff -r" or "diff --git" in the text. $this->saveLine(); $line = $this->nextLineThatLooksLikeDiffStart(); if (!$this->tryMatchHeader($patterns, $line, $match)) { // Restore line before guessing to display correct error. $this->restoreLine(); $failed_parse = true; } } else if (!$ok) { $failed_parse = true; } if ($failed_parse) { $this->didFailParse( pht( "Expected a hunk header, like '%s' (svn), '%s' (svn properties), ". "'%s' (git show), '%s' (git diff), '%s' (unified diff), or ". "'%s' (hg diff or patch).", 'Index: /path/to/file.ext', 'Property changes on: /path/to/file.ext', 'commit 59bcc3ad6775562f845953cf01624225', 'diff --git', '--- filename', 'diff -r')); } if (isset($match['type'])) { if ($match['type'] == 'diff --git') { list($old, $new) = self::splitGitDiffPaths($match['oldnew']); $match['old'] = $old; $match['cur'] = $new; } } $change = $this->buildChange(idx($match, 'cur')); if (isset($match['old'])) { $change->setOldPath($match['old']); } if (isset($match['hash'])) { $change->setCommitHash($match['hash']); } if (isset($match['binary'])) { $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); $line = $this->nextNonemptyLine(); continue; } $line = $this->nextLine(); switch ($match['type']) { case 'Index': case 'Modified': case 'Added': case 'Deleted': case 'Copied': $this->parseIndexHunk($change); break; case 'Property changes on': $this->parsePropertyHunk($change); break; case 'diff --git': $this->setIsGit(true); $this->parseIndexHunk($change); break; case 'commit': $this->setIsGit(true); $this->parseCommitMessage($change); break; case '---': $ok = preg_match( '@^(?:\+\+\+) (.*)\s+\d{4}-\d{2}-\d{2}.*$@', $line, $match); if (!$ok) { $this->didFailParse(pht( "Expected '%s' in unified diff.", '+++ filename')); } $change->setCurrentPath($match[1]); $line = $this->nextLine(); $this->parseChangeset($change); break; case 'diff -r': $this->setIsMercurial(true); $this->parseIndexHunk($change); break; case 'rcsdiff -u': $this->isRCS = true; $this->parseIndexHunk($change); break; default: $this->didFailParse(pht('Unknown diff type.')); break; } } while ($this->getLine() !== null); $this->didFinishParse(); $this->loadSyntheticData(); return $this->changes; } protected function tryMatchHeader($patterns, $line, &$match) { foreach ($patterns as $pattern) { if (preg_match('@^'.$pattern.'$@', $line, $match)) { return true; } } return false; } protected function parseCommitMessage(ArcanistDiffChange $change) { $change->setType(ArcanistDiffChangeType::TYPE_MESSAGE); $message = array(); $line = $this->getLine(); if (preg_match('/^Merge: /', $line)) { $this->nextLine(); } $line = $this->getLine(); if (!preg_match('/^Author: /', $line)) { $this->didFailParse(pht("Expected 'Author:'.")); } $line = $this->nextLine(); if (!preg_match('/^Date: /', $line)) { $this->didFailParse(pht("Expected 'Date:'.")); } while (($line = $this->nextLineTrimmed()) !== null) { if (strlen($line) && $line[0] != ' ') { break; } // Strip leading spaces from Git commit messages. Note that empty lines // are represented as just "\n"; don't touch those. $message[] = preg_replace('/^ /', '', $this->getLine()); } $message = rtrim(implode('', $message), "\r\n"); $change->setMetadata('message', $message); } /** * Parse an SVN property change hunk. These hunks are ambiguous so just sort * of try to get it mostly right. It's entirely possible to foil this parser * (or any other parser) with a carefully constructed property change. */ protected function parsePropertyHunk(ArcanistDiffChange $change) { $line = $this->getLineTrimmed(); if (!preg_match('/^_+$/', $line)) { $this->didFailParse(pht("Expected '%s'.", '______________________')); } $line = $this->nextLine(); while ($line !== null) { $done = preg_match('/^(Index|Property changes on):/', $line); if ($done) { break; } // NOTE: Before 1.5, SVN uses "Name". At 1.5 and later, SVN uses // "Modified", "Added" and "Deleted". $matches = null; $ok = preg_match( '/^(Name|Modified|Added|Deleted): (.*)$/', $line, $matches); if (!$ok) { $this->didFailParse( pht("Expected 'Name', 'Added', 'Deleted', or 'Modified'.")); } $op = $matches[1]; $prop = $matches[2]; list($old, $new) = $this->parseSVNPropertyChange($op, $prop); if ($old !== null) { $change->setOldProperty($prop, $old); } if ($new !== null) { $change->setNewProperty($prop, $new); } $line = $this->getLine(); } } private function parseSVNPropertyChange($op, $prop) { $old = array(); $new = array(); $target = null; $line = $this->nextLine(); $prop_index = 2; while ($line !== null) { $done = preg_match( '/^(Modified|Added|Deleted|Index|Property changes on):/', $line); if ($done) { break; } $trimline = ltrim($line); if ($trimline && $trimline[0] == '#') { // in svn1.7, a line like ## -0,0 +1 ## is put between the Added: line // and the line with the property change. If we have such a line, we'll // just ignore it (: $line = $this->nextLine(); $prop_index = 1; $trimline = ltrim($line); } if ($trimline && $trimline[0] == '+') { if ($op == 'Deleted') { $this->didFailParse(pht( 'Unexpected "%s" section in property deletion.', '+')); } $target = 'new'; $line = substr($trimline, $prop_index); } else if ($trimline && $trimline[0] == '-') { if ($op == 'Added') { $this->didFailParse(pht( 'Unexpected "%s" section in property addition.', '-')); } $target = 'old'; $line = substr($trimline, $prop_index); } else if (!strncmp($trimline, 'Merged', 6)) { if ($op == 'Added') { $target = 'new'; } else { // These can appear on merges. No idea how to interpret this (unclear // what the old / new values are) and it's of dubious usefulness so // just throw it away until someone complains. $target = null; } $line = $trimline; } if ($target == 'new') { $new[] = $line; } else if ($target == 'old') { $old[] = $line; } $line = $this->nextLine(); } $old = rtrim(implode('', $old)); $new = rtrim(implode('', $new)); if (!strlen($old)) { $old = null; } if (!strlen($new)) { $new = null; } return array($old, $new); } protected function setIsGit($git) { if ($this->isGit !== null && $this->isGit != $git) { throw new Exception(pht('Git status has changed!')); } $this->isGit = $git; return $this; } protected function getIsGit() { return $this->isGit; } public function setIsMercurial($is_mercurial) { $this->isMercurial = $is_mercurial; return $this; } public function getIsMercurial() { return $this->isMercurial; } protected function parseIndexHunk(ArcanistDiffChange $change) { $is_git = $this->getIsGit(); $is_mercurial = $this->getIsMercurial(); $is_svn = (!$is_git && !$is_mercurial); $move_source = null; $line = $this->getLine(); if ($is_git) { do { $patterns = array( '(?Pnew) file mode (?P\d+)', '(?Pdeleted) file mode (?P\d+)', // These occur when someone uses `chmod` on a file. 'old mode (?P\d+)', 'new mode (?P\d+)', // These occur when you `mv` a file and git figures it out. 'similarity index ', 'rename from (?P.*)', '(?Prename) to (?P.*)', 'copy from (?P.*)', '(?Pcopy) to (?P.*)', ); $ok = false; $match = null; foreach ($patterns as $pattern) { $ok = preg_match('@^'.$pattern.'@', $line, $match); if ($ok) { break; } } if (!$ok) { if ($line === null || preg_match('/^(diff --git|commit) /', $line)) { // In this case, there are ONLY file mode changes, or this is a // pure move. If it's a move, flag these changesets so we can build // synthetic changes later, enabling us to show file contents in // Differential -- git only gives us a block like this: // // diff --git a/README b/READYOU // similarity index 100% // rename from README // rename to READYOU // // ...i.e., there is no associated diff. // This allows us to distinguish between property changes only // and actual moves. For property changes only, we can't currently // build a synthetic diff correctly, so just skip it. // TODO: Build synthetic diffs for property changes, too. if ($change->getType() != ArcanistDiffChangeType::TYPE_CHANGE) { $change->setNeedsSyntheticGitHunks(true); if ($move_source) { $move_source->setNeedsSyntheticGitHunks(true); } } return; } break; } if (!empty($match['oldmode'])) { $change->setOldProperty('unix:filemode', $match['oldmode']); } if (!empty($match['newmode'])) { $change->setNewProperty('unix:filemode', $match['newmode']); } if (!empty($match['deleted'])) { $change->setType(ArcanistDiffChangeType::TYPE_DELETE); } if (!empty($match['new'])) { // If you replace a symlink with a normal file, git renders the change // as a "delete" of the symlink plus an "add" of the new file. We // prefer to represent this as a change. if ($change->getType() == ArcanistDiffChangeType::TYPE_DELETE) { $change->setType(ArcanistDiffChangeType::TYPE_CHANGE); } else { $change->setType(ArcanistDiffChangeType::TYPE_ADD); } } if (!empty($match['old'])) { $match['old'] = self::unescapeFilename($match['old']); $change->setOldPath($match['old']); } if (!empty($match['cur'])) { $match['cur'] = self::unescapeFilename($match['cur']); $change->setCurrentPath($match['cur']); } if (!empty($match['copy'])) { $change->setType(ArcanistDiffChangeType::TYPE_COPY_HERE); $old = $this->buildChange($change->getOldPath()); $type = $old->getType(); if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else { $old->setType(ArcanistDiffChangeType::TYPE_COPY_AWAY); } $old->addAwayPath($change->getCurrentPath()); } if (!empty($match['move'])) { $change->setType(ArcanistDiffChangeType::TYPE_MOVE_HERE); $old = $this->buildChange($change->getOldPath()); $type = $old->getType(); if ($type == ArcanistDiffChangeType::TYPE_MULTICOPY) { // Great, no change. } else if ($type == ArcanistDiffChangeType::TYPE_MOVE_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else if ($type == ArcanistDiffChangeType::TYPE_COPY_AWAY) { $old->setType(ArcanistDiffChangeType::TYPE_MULTICOPY); } else { $old->setType(ArcanistDiffChangeType::TYPE_MOVE_AWAY); } // We'll reference this above. $move_source = $old; $old->addAwayPath($change->getCurrentPath()); } $line = $this->nextNonemptyLine(); } while (true); } $line = $this->getLine(); if ($is_svn) { $ok = preg_match('/^=+\s*$/', $line); if (!$ok) { $this->didFailParse(pht( "Expected '%s' divider line.", '=======================')); } else { // Adding an empty file in SVN can produce an empty line here. $line = $this->nextNonemptyLine(); } } else if ($is_git) { $ok = preg_match('/^index .*$/', $line); if (!$ok) { // TODO: "hg diff -g" diffs ("mercurial git-style diffs") do not include // this line, so we can't parse them if we fail on it. Maybe introduce // a flag saying "parse this diff using relaxed git-style diff rules"? // $this->didFailParse("Expected 'index af23f...a98bc' header line."); } else { // NOTE: In the git case, where this patch is the last change in the // file, we may have a final terminal newline. Skip over it so that // we'll hit the '$line === null' block below. This is covered by the // 'git-empty-file.gitdiff' test case. $line = $this->nextNonemptyLine(); } } // If there are files with only whitespace changes and -b or -w are // supplied as command-line flags to `diff', svn and git both produce // changes without any body. if ($line === null || preg_match( '/^(Index:|Property changes on:|diff --git|commit) /', $line)) { return; } $is_binary_add = preg_match( '/^Cannot display: file marked as a binary type\.$/', rtrim($line)); if ($is_binary_add) { $this->nextLine(); // Cannot display: file marked as a binary type. $this->nextNonemptyLine(); // svn:mime-type = application/octet-stream $this->markBinary($change); return; } // We can get this in git, or in SVN when a file exists in the repository // WITHOUT a binary mime-type and is changed and given a binary mime-type. $is_binary_diff = preg_match( '/^(Binary files|Files) .* and .* differ$/', rtrim($line)); if ($is_binary_diff) { $this->nextNonemptyLine(); // Binary files x and y differ $this->markBinary($change); return; } // This occurs under "hg diff --git" when a binary file is removed. See // test case "hg-binary-delete.hgdiff". (I believe it never occurs under // git, which reports the "files X and /dev/null differ" string above. Git // can not apply these patches.) $is_hg_binary_delete = preg_match( '/^Binary file .* has changed$/', rtrim($line)); if ($is_hg_binary_delete) { $this->nextNonemptyLine(); $this->markBinary($change); return; } // With "git diff --binary" (not a normal mode, but one users may explicitly // invoke and then, e.g., copy-paste into the web console) or "hg diff // --git" (normal under hg workflows), we may encounter a literal binary // patch. $is_git_binary_patch = preg_match( '/^GIT binary patch$/', rtrim($line)); if ($is_git_binary_patch) { $this->nextLine(); $this->parseGitBinaryPatch(); $line = $this->getLine(); if (preg_match('/^literal/', $line)) { // We may have old/new binaries (change) or just a new binary (hg add). // If there are two blocks, parse both. $this->parseGitBinaryPatch(); } $this->markBinary($change); return; } if ($is_git) { // "git diff -b" ignores whitespace, but has an empty hunk target if (preg_match('@^diff --git .*$@', $line)) { $this->nextLine(); return null; } } if ($this->isRCS) { // Skip the RCS headers. $this->nextLine(); $this->nextLine(); $this->nextLine(); } $old_file = $this->parseHunkTarget(); $new_file = $this->parseHunkTarget(); if ($this->isRCS) { $change->setCurrentPath($new_file); } $change->setOldPath($old_file); $this->parseChangeset($change); } private function parseGitBinaryPatch() { // TODO: We could decode the patches, but it's a giant mess so don't bother // for now. We'll pick up the data from the working copy in the common // case ("arc diff"). $line = $this->getLine(); if (!preg_match('/^literal /', $line)) { $this->didFailParse( pht("Expected '%s' to start git binary patch.", 'literal NNNN')); } do { $line = $this->nextLineTrimmed(); if ($line === '' || $line === null) { // Some versions of Mercurial apparently omit the terminal newline, // although it's unclear if Git will ever do this. In either case, // rely on the base85 check for sanity. $this->nextNonemptyLine(); return; } else if (!preg_match('/^[a-zA-Z]/', $line)) { $this->didFailParse( pht('Expected base85 line length character (a-zA-Z).')); } } while (true); } protected function parseHunkTarget() { $line = $this->getLine(); $matches = null; $remainder = '(?:\s*\(.*\))?'; if ($this->getIsMercurial()) { // Something like "Fri Aug 26 01:20:50 2005 -0700", don't bother trying // to parse it. $remainder = '\t.*'; } else if ($this->isRCS) { $remainder = '\s.*'; } else if ($this->getIsGit()) { // When filenames contain spaces, Git terminates this line with a tab. // Normally, the tab is not present. If there's a tab, ignore it. $remainder = '(?:\t.*)?'; } $ok = preg_match( '@^[-+]{3} (?:[ab]/)?(?P.*?)'.$remainder.'$@', $line, $matches); if (!$ok) { $this->didFailParse( pht( "Expected hunk target '%s'.", '+++ path/to/file.ext (revision N)')); } $this->nextLine(); return $matches['path']; } protected function markBinary(ArcanistDiffChange $change) { $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); return $this; } protected function parseChangeset(ArcanistDiffChange $change) { // If a diff includes two sets of changes to the same file, let the // second one win. In particular, this occurs when adding subdirectories // in Subversion that contain files: the file text will be present in // both the directory diff and the file diff. See T5555. Dropping the // hunks lets whichever one shows up later win instead of showing changes // twice. $change->dropHunks(); $all_changes = array(); do { $hunk = new ArcanistDiffHunk(); $line = $this->getLineTrimmed(); $real = array(); // In the case where only one line is changed, the length is omitted. // The final group is for git, which appends a guess at the function // context to the diff. $matches = null; $ok = preg_match( '/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*?)?$/U', $line, $matches); if (!$ok) { // It's possible we hit the style of an svn1.7 property change. // This is a 4-line Index block, followed by an empty line, followed // by a "Property changes on:" section similar to svn1.6. if ($line == '') { $line = $this->nextNonemptyLine(); $ok = preg_match('/^Property changes on:/', $line); if (!$ok) { $this->didFailParse(pht('Confused by empty line')); } $line = $this->nextLine(); return $this->parsePropertyHunk($change); } $this->didFailParse(pht( "Expected hunk header '%s'.", '@@ -NN,NN +NN,NN @@')); } $hunk->setOldOffset($matches[1]); $hunk->setNewOffset($matches[3]); // Cover for the cases where length wasn't present (implying one line). $old_len = idx($matches, 2); if (!strlen($old_len)) { $old_len = 1; } $new_len = idx($matches, 4); if (!strlen($new_len)) { $new_len = 1; } $hunk->setOldLength($old_len); $hunk->setNewLength($new_len); $add = 0; $del = 0; $hit_next_hunk = false; while ((($line = $this->nextLine()) !== null)) { if (strlen(rtrim($line, "\r\n"))) { $char = $line[0]; } else { // Normally, we do not encouter empty lines in diffs, because // unchanged lines have an initial space. However, in Git, with // the option `diff.suppress-blank-empty` set, unchanged blank lines // emit as completely empty. If we encounter a completely empty line, // treat it as a ' ' (i.e., unchanged empty line) line. $char = ' '; } switch ($char) { case '\\': if (!preg_match('@\\ No newline at end of file@', $line)) { $this->didFailParse( pht("Expected '\ No newline at end of file'.")); } if ($new_len) { $real[] = $line; $hunk->setIsMissingOldNewline(true); } else { $real[] = $line; $hunk->setIsMissingNewNewline(true); } if (!$new_len) { break 2; } break; case '+': ++$add; --$new_len; $real[] = $line; break; case '-': if (!$old_len) { // In this case, we've hit "---" from a new file. So don't // advance the line cursor. $hit_next_hunk = true; break 2; } ++$del; --$old_len; $real[] = $line; break; case ' ': if (!$old_len && !$new_len) { break 2; } --$old_len; --$new_len; $real[] = $line; break; default: // We hit something, likely another hunk. $hit_next_hunk = true; break 2; } } if ($old_len || $new_len) { $this->didFailParse(pht('Found the wrong number of hunk lines.')); } $corpus = implode('', $real); $is_binary = false; if ($this->detectBinaryFiles) { $is_binary = !phutil_is_utf8($corpus); $try_encoding = $this->tryEncoding; if ($is_binary && $try_encoding) { $is_binary = ArcanistDiffUtils::isHeuristicBinaryFile($corpus); if (!$is_binary) { $corpus = phutil_utf8_convert($corpus, 'UTF-8', $try_encoding); if (!phutil_is_utf8($corpus)) { throw new Exception( pht( "Failed to convert a hunk from '%s' to UTF-8. ". "Check that the specified encoding is correct.", $try_encoding)); } } } } if ($is_binary) { // SVN happily treats binary files which aren't marked with the right // mime type as text files. Detect that junk here and mark the file // binary. We'll catch stuff with unicode too, but that's verboten // anyway. If there are too many false positives with this we might // need to make it threshold-triggered instead of triggering on any // unprintable byte. $change->setFileType(ArcanistDiffChangeType::FILE_BINARY); } else { $hunk->setCorpus($corpus); $hunk->setAddLines($add); $hunk->setDelLines($del); $change->addHunk($hunk); } if (!$hit_next_hunk) { $line = $this->nextNonemptyLine(); } } while (preg_match('/^@@ /', $line)); } protected function buildChange($path = null) { $change = null; if ($path !== null) { if (!empty($this->changes[$path])) { return $this->changes[$path]; } } if ($this->forcePath) { return $this->changes[$this->forcePath]; } $change = new ArcanistDiffChange(); if ($path !== null) { $change->setCurrentPath($path); $this->changes[$path] = $change; } else { $this->changes[] = $change; } return $change; } protected function didStartParse($text) { $this->rawDiff = $text; // Eat leading whitespace. This may happen if the first change in the diff // is an SVN property change. $text = ltrim($text); // Try to strip ANSI color codes from colorized diffs. ANSI color codes // might be present in two cases: // // - You piped a colorized diff into 'arc --raw' or similar (normally // we're able to disable colorization on diffs we control the generation // of). // - You're diffing a file which actually contains ANSI color codes. // // The former is vastly more likely, but we try to distinguish between the // two cases by testing for a color code at the beginning of a line. If // we find one, we know it's a colorized diff (since the beginning of the // line should be "+", "-" or " " if the code is in the diff text). // // While it's possible a diff might be colorized and fail this test, it's // unlikely, and it covers hg's color extension which seems to be the most // stubborn about colorizing text despite stdout not being a TTY. // // We might incorrectly strip color codes from a colorized diff of a text // file with color codes inside it, but this case is stupid and pathological // and you've dug your own grave. $ansi_color_pattern = '\x1B\[[\d;]*m'; if (preg_match('/^'.$ansi_color_pattern.'/m', $text)) { $text = preg_replace('/'.$ansi_color_pattern.'/', '', $text); } $this->text = phutil_split_lines($text); $this->line = 0; } protected function getLine() { if ($this->text === null) { throw new Exception(pht('Not parsing!')); } if (isset($this->text[$this->line])) { return $this->text[$this->line]; } return null; } protected function getLineTrimmed() { $line = $this->getLine(); if ($line !== null) { $line = trim($line, "\r\n"); } return $line; } protected function nextLine() { $this->line++; return $this->getLine(); } protected function nextLineTrimmed() { $line = $this->nextLine(); if ($line !== null) { $line = trim($line, "\r\n"); } return $line; } protected function nextNonemptyLine() { while (($line = $this->nextLine()) !== null) { if (strlen(trim($line)) !== 0) { break; } } return $this->getLine(); } protected function nextLineThatLooksLikeDiffStart() { while (($line = $this->nextLine()) !== null) { if (preg_match('/^\s*diff\s+-(?:r|-git)/', $line)) { break; } } return $this->getLine(); } protected function saveLine() { $this->lineSaved = $this->line; } protected function restoreLine() { $this->line = $this->lineSaved; } protected function isFirstNonEmptyLine() { $len = count($this->text); for ($ii = 0; $ii < $len; $ii++) { $line = $this->text[$ii]; if (!strlen(trim($line))) { // This line is empty, skip it. continue; } if (preg_match('/^#/', $line)) { // This line is a comment, skip it. continue; } return ($ii == $this->line); } // Entire file is empty. return false; } protected function didFinishParse() { $this->text = null; } public function setWriteDiffOnFailure($write) { $this->writeDiffOnFailure = $write; return $this; } protected function didFailParse($message) { $context = 5; $min = max(0, $this->line - $context); $max = min($this->line + $context, count($this->text) - 1); $context = ''; for ($ii = $min; $ii <= $max; $ii++) { $context .= sprintf( '%8.8s %6.6s %s', ($ii == $this->line) ? '>>> ' : '', $ii + 1, $this->text[$ii]); } $out = array(); $out[] = pht('Diff Parse Exception: %s', $message); if ($this->writeDiffOnFailure) { $temp = new TempFile(); $temp->setPreserveFile(true); Filesystem::writeFile($temp, $this->rawDiff); $out[] = pht('Raw input file was written to: %s', $temp); } $out[] = $context; $out = implode("\n\n", $out); throw new Exception($out); } /** * Unescape escaped filenames, e.g. from "git diff". */ private static function unescapeFilename($name) { if (preg_match('/^".+"$/', $name)) { return stripcslashes(substr($name, 1, -1)); } else { return $name; } } private function loadSyntheticData() { if (!$this->changes) { return; } $repository_api = $this->repositoryAPI; if (!$repository_api) { return; } $imagechanges = array(); $changes = $this->changes; foreach ($changes as $change) { $path = $change->getCurrentPath(); // Certain types of changes (moves and copies) don't contain change data // when expressed in raw "git diff" form. Augment any such diffs with // textual data. if ($change->getNeedsSyntheticGitHunks() && ($repository_api instanceof ArcanistGitAPI)) { $diff = $repository_api->getRawDiffText($path, $moves = false); // NOTE: We're reusing the parser and it doesn't reset change state // between parses because there's an oddball SVN workflow in Phabricator // which relies on being able to inject changes. // TODO: Fix this. $parser = clone $this; $parser->setChanges(array()); $raw_changes = $parser->parseDiff($diff); foreach ($raw_changes as $raw_change) { if ($raw_change->getCurrentPath() == $path) { $change->setFileType($raw_change->getFileType()); foreach ($raw_change->getHunks() as $hunk) { // Git thinks that this file has been added. But we know that it // has been moved or copied without a change. $hunk->setCorpus( preg_replace('/^\+/m', ' ', $hunk->getCorpus())); $change->addHunk($hunk); } break; } } $change->setNeedsSyntheticGitHunks(false); } if ($change->getFileType() != ArcanistDiffChangeType::FILE_BINARY && $change->getFileType() != ArcanistDiffChangeType::FILE_IMAGE) { continue; } $imagechanges[$path] = $change; } // Fetch the actual file contents in batches so repositories // that have slow random file accesses (i.e. mercurial) can // optimize the retrieval. $paths = array_keys($imagechanges); $filedata = $repository_api->getBulkOriginalFileData($paths); foreach ($filedata as $path => $data) { $imagechanges[$path]->setOriginalFileData($data); } $filedata = $repository_api->getBulkCurrentFileData($paths); foreach ($filedata as $path => $data) { $imagechanges[$path]->setCurrentFileData($data); } $this->changes = $changes; } /** * Strip prefixes off paths from `git diff`. By default git uses a/ and b/, * but you can set `diff.mnemonicprefix` to get a different set of prefixes, * or use `--no-prefix`, `--src-prefix` or `--dst-prefix` to set these to * other arbitrary values. * * We strip the default and mnemonic prefixes, and trust the user knows what * they're doing in the other cases. * * @param string Path to strip. * @return string Stripped path. */ public static function stripGitPathPrefix($path) { static $regex; if ($regex === null) { $prefixes = array( // These are the defaults. 'a/', 'b/', // These show up when you set "diff.mnemonicprefix". 'i/', 'c/', 'w/', 'o/', '1/', '2/', ); foreach ($prefixes as $key => $prefix) { $prefixes[$key] = preg_quote($prefix, '@'); } $regex = '@^('.implode('|', $prefixes).')@S'; } return preg_replace($regex, '', $path); } /** * Split the paths on a "diff --git" line into old and new paths. This * is difficult because they may be ambiguous if the files contain spaces. * * @param string Text from a diff line after "diff --git ". * @return pair Old and new paths. */ public static function splitGitDiffPaths($paths) { $matches = null; $paths = rtrim($paths, "\r\n"); $patterns = array( // Try quoted paths, used for unicode filenames or filenames with quotes. '@^(?P"(?:\\\\.|[^"\\\\]+)+") (?P"(?:\\\\.|[^"\\\\]+)+")$@', // Try paths without spaces. '@^(?P[^ ]+) (?P[^ ]+)$@', // Try paths with well-known prefixes. '@^(?P[abicwo12]/.*) (?P[abicwo12]/.*)$@', // Try the exact same string twice in a row separated by a space. // This can hit a false positive for moves from files like "old file old" // to "file", but such a case combined with custom diff prefixes is // incredibly obscure. '@^(?P.*) (?P\\1)$@', ); foreach ($patterns as $pattern) { if (preg_match($pattern, $paths, $matches)) { break; } } if (!$matches) { throw new Exception( pht( "Input diff contains ambiguous line '%s'. This line is ambiguous ". "because there are spaces in the file names, so the parser can not ". "determine where the file names begin and end. To resolve this ". "ambiguity, use standard prefixes ('a/' and 'b/') when ". "generating diffs.", "diff --git {$paths}")); } $old = $matches['old']; $old = self::unescapeFilename($old); $old = self::stripGitPathPrefix($old); $new = $matches['new']; $new = self::unescapeFilename($new); $new = self::stripGitPathPrefix($new); return array($old, $new); } /** * Strip the header and footer off a `git-format-patch` diff. * * Returns a parseable normal diff and a textual commit message. */ private function stripGitFormatPatch($diff) { // We can parse this by splitting it into two pieces over and over again // along different section dividers: // // 1. Mail headers. // 2. ("\n\n") // 3. Mail body. // 4. ("---") // 5. Diff stat section. // 6. ("\n\n") // 7. Actual diff body. // 8. ("--") // 9. Patch footer. list($head, $tail) = preg_split('/^---$/m', $diff, 2); list($mail_headers, $mail_body) = explode("\n\n", $head, 2); list($body, $foot) = preg_split('/^-- ?$/m', $tail, 2); list($stat, $diff) = explode("\n\n", $body, 2); // Rebuild the commit message by putting the subject line back on top of it, // if we can find one. $matches = null; $pattern = '/^Subject: (?:\[PATCH\] )?(.*)$/mi'; if (preg_match($pattern, $mail_headers, $matches)) { $mail_body = $matches[1]."\n\n".$mail_body; $mail_body = rtrim($mail_body); } return array($mail_body, $diff); } } diff --git a/src/parser/diff/ArcanistDiffChange.php b/src/parser/diff/ArcanistDiffChange.php index 06f68b6c..24bcb74d 100644 --- a/src/parser/diff/ArcanistDiffChange.php +++ b/src/parser/diff/ArcanistDiffChange.php @@ -1,316 +1,316 @@ originalFileData = $original_file_data; return $this; } public function getOriginalFileData() { return $this->originalFileData; } public function setCurrentFileData($current_file_data) { $this->currentFileData = $current_file_data; return $this; } public function getCurrentFileData() { return $this->currentFileData; } public function toDictionary() { $hunks = array(); foreach ($this->hunks as $hunk) { $hunks[] = $hunk->toDictionary(); } return array( 'metadata' => $this->metadata, 'oldPath' => $this->oldPath, 'currentPath' => $this->currentPath, 'awayPaths' => $this->awayPaths, 'oldProperties' => $this->oldProperties, 'newProperties' => $this->newProperties, 'type' => $this->type, 'fileType' => $this->fileType, 'commitHash' => $this->commitHash, 'hunks' => $hunks, ); } public static function newFromDictionary(array $dict) { $hunks = array(); foreach ($dict['hunks'] as $hunk) { $hunks[] = ArcanistDiffHunk::newFromDictionary($hunk); } $obj = new ArcanistDiffChange(); $obj->metadata = $dict['metadata']; $obj->oldPath = $dict['oldPath']; $obj->currentPath = $dict['currentPath']; // TODO: The backend is shipping down some bogus data, e.g. diff 199453. // Should probably clean this up. $obj->awayPaths = nonempty($dict['awayPaths'], array()); $obj->oldProperties = nonempty($dict['oldProperties'], array()); $obj->newProperties = nonempty($dict['newProperties'], array()); $obj->type = $dict['type']; $obj->fileType = $dict['fileType']; $obj->commitHash = $dict['commitHash']; $obj->hunks = $hunks; return $obj; } public static function newFromConduit(array $dicts) { $changes = array(); foreach ($dicts as $dict) { $changes[] = self::newFromDictionary($dict); } return $changes; } public function getChangedLines($type) { $lines = array(); foreach ($this->hunks as $hunk) { $lines += $hunk->getChangedLines($type); } return $lines; } public function getAllMetadata() { return $this->metadata; } public function setMetadata($key, $value) { $this->metadata[$key] = $value; return $this; } public function getMetadata($key) { return idx($this->metadata, $key); } public function setCommitHash($hash) { $this->commitHash = $hash; return $this; } public function getCommitHash() { return $this->commitHash; } public function addAwayPath($path) { $this->awayPaths[] = $path; return $this; } public function getAwayPaths() { return $this->awayPaths; } public function setFileType($type) { $this->fileType = $type; return $this; } public function getFileType() { return $this->fileType; } public function setType($type) { $this->type = $type; return $this; } public function getType() { return $this->type; } public function setOldProperty($key, $value) { $this->oldProperties[$key] = $value; return $this; } public function setNewProperty($key, $value) { $this->newProperties[$key] = $value; return $this; } public function getOldProperties() { return $this->oldProperties; } public function getNewProperties() { return $this->newProperties; } public function setCurrentPath($path) { $this->currentPath = $this->filterPath($path); return $this; } public function getCurrentPath() { return $this->currentPath; } public function setOldPath($path) { $this->oldPath = $this->filterPath($path); return $this; } public function getOldPath() { return $this->oldPath; } public function addHunk(ArcanistDiffHunk $hunk) { $this->hunks[] = $hunk; return $this; } public function dropHunks() { $this->hunks = array(); return $this; } public function getHunks() { return $this->hunks; } /** * @return array $old => array($new, ) */ public function buildLineMap() { $line_map = array(); $old = 1; $new = 1; foreach ($this->getHunks() as $hunk) { for ($n = $old; $n < $hunk->getOldOffset(); $n++) { $line_map[$n] = array($n + $new - $old); } $old = $hunk->getOldOffset(); $new = $hunk->getNewOffset(); $olds = array(); $news = array(); $lines = explode("\n", $hunk->getCorpus()); foreach ($lines as $line) { $type = substr($line, 0, 1); if ($type == '-' || $type == ' ') { $olds[] = $old; $old++; } if ($type == '+' || $type == ' ') { $news[] = $new; $new++; } if ($type == ' ' || $type == '') { $line_map += array_fill_keys($olds, $news); $olds = array(); $news = array(); } } } return $line_map; } public function convertToBinaryChange(ArcanistRepositoryAPI $api) { // Fill in the binary data from the working copy. $this->setOriginalFileData( $api->getOriginalFileData( $this->getOldPath())); $this->setCurrentFileData( $api->getCurrentFileData( $this->getCurrentPath())); $this->hunks = array(); $this->setFileType(ArcanistDiffChangeType::FILE_BINARY); return $this; } protected function filterPath($path) { if ($path == '/dev/null') { return null; } return $path; } public function renderTextSummary() { $type = $this->getType(); $file = $this->getFileType(); $char = ArcanistDiffChangeType::getSummaryCharacterForChangeType($type); $attr = ArcanistDiffChangeType::getShortNameForFileType($file); if ($attr) { $attr = '('.$attr.')'; } $summary = array(); $summary[] = sprintf( '%s %5.5s %s', $char, $attr, $this->getCurrentPath()); if (ArcanistDiffChangeType::isOldLocationChangeType($type)) { foreach ($this->getAwayPaths() as $path) { $summary[] = ' to: '.$path; } } if (ArcanistDiffChangeType::isNewLocationChangeType($type)) { $summary[] = ' from: '.$this->getOldPath(); } return implode("\n", $summary); } public function getSymlinkTarget() { if ($this->getFileType() != ArcanistDiffChangeType::FILE_SYMLINK) { throw new Exception(pht('Not a symlink!')); } $hunks = $this->getHunks(); $hunk = reset($hunks); $corpus = $hunk->getCorpus(); $match = null; if (!preg_match('/^\+(?:link )?(.*)$/m', $corpus, $match)) { throw new Exception(pht('Failed to extract link target!')); } return trim($match[1]); } public function setNeedsSyntheticGitHunks($needs_synthetic_git_hunks) { $this->needsSyntheticGitHunks = $needs_synthetic_git_hunks; return $this; } public function getNeedsSyntheticGitHunks() { return $this->needsSyntheticGitHunks; } } diff --git a/src/parser/diff/ArcanistDiffChangeType.php b/src/parser/diff/ArcanistDiffChangeType.php index 36ea5a5c..ec1502d4 100644 --- a/src/parser/diff/ArcanistDiffChangeType.php +++ b/src/parser/diff/ArcanistDiffChangeType.php @@ -1,117 +1,117 @@ 'A', self::TYPE_CHANGE => 'M', self::TYPE_DELETE => 'D', self::TYPE_MOVE_AWAY => 'V', self::TYPE_COPY_AWAY => 'P', self::TYPE_MOVE_HERE => 'V', self::TYPE_COPY_HERE => 'P', self::TYPE_MULTICOPY => 'P', self::TYPE_MESSAGE => 'Q', self::TYPE_CHILD => '@', ); return idx($types, coalesce($type, '?'), '~'); } public static function getShortNameForFileType($type) { static $names = array( self::FILE_TEXT => null, self::FILE_DIRECTORY => 'dir', self::FILE_IMAGE => 'img', self::FILE_BINARY => 'bin', self::FILE_SYMLINK => 'sym', ); return idx($names, coalesce($type, '?'), '???'); } public static function isOldLocationChangeType($type) { static $types = array( self::TYPE_MOVE_AWAY => true, self::TYPE_COPY_AWAY => true, self::TYPE_MULTICOPY => true, ); return isset($types[$type]); } public static function isNewLocationChangeType($type) { static $types = array( self::TYPE_MOVE_HERE => true, self::TYPE_COPY_HERE => true, ); return isset($types[$type]); } public static function isDeleteChangeType($type) { static $types = array( self::TYPE_DELETE => true, self::TYPE_MOVE_AWAY => true, self::TYPE_MULTICOPY => true, ); return isset($types[$type]); } public static function isCreateChangeType($type) { static $types = array( self::TYPE_ADD => true, self::TYPE_COPY_HERE => true, self::TYPE_MOVE_HERE => true, ); return isset($types[$type]); } public static function isModifyChangeType($type) { static $types = array( self::TYPE_CHANGE => true, ); return isset($types[$type]); } public static function getFullNameForChangeType($type) { static $types = null; if ($types === null) { $types = array( self::TYPE_ADD => pht('Added'), self::TYPE_CHANGE => pht('Modified'), self::TYPE_DELETE => pht('Deleted'), self::TYPE_MOVE_AWAY => pht('Moved Away'), self::TYPE_COPY_AWAY => pht('Copied Away'), self::TYPE_MOVE_HERE => pht('Moved Here'), self::TYPE_COPY_HERE => pht('Copied Here'), self::TYPE_MULTICOPY => pht('Deleted After Multiple Copy'), self::TYPE_MESSAGE => pht('Commit Message'), self::TYPE_CHILD => pht('Contents Modified'), ); } return idx($types, coalesce($type, '?'), pht('Unknown')); } } diff --git a/src/parser/diff/ArcanistDiffHunk.php b/src/parser/diff/ArcanistDiffHunk.php index cfd6d998..d8c8584c 100644 --- a/src/parser/diff/ArcanistDiffHunk.php +++ b/src/parser/diff/ArcanistDiffHunk.php @@ -1,171 +1,171 @@ $this->oldOffset, 'newOffset' => $this->newOffset, 'oldLength' => $this->oldLength, 'newLength' => $this->newLength, 'addLines' => $this->addLines, 'delLines' => $this->delLines, 'isMissingOldNewline' => $this->isMissingOldNewline, 'isMissingNewNewline' => $this->isMissingNewNewline, 'corpus' => (string)$this->corpus, ); } public static function newFromDictionary(array $dict) { $obj = new ArcanistDiffHunk(); $obj->oldOffset = $dict['oldOffset']; $obj->newOffset = $dict['newOffset']; $obj->oldLength = $dict['oldLength']; $obj->newLength = $dict['newLength']; $obj->addLines = $dict['addLines']; $obj->delLines = $dict['delLines']; $obj->isMissingOldNewline = $dict['isMissingOldNewline']; $obj->isMissingNewNewline = $dict['isMissingNewNewline']; $obj->corpus = $dict['corpus']; return $obj; } public function getChangedLines($type) { $old_map = array(); $new_map = array(); $cover_map = array(); $oline = $this->getOldOffset(); $nline = $this->getNewOffset(); foreach (explode("\n", $this->getCorpus()) as $line) { $char = strlen($line) ? $line[0] : '~'; switch ($char) { case '-': $old_map[$oline] = true; $cover_map[$oline] = true; ++$oline; break; case '+': $new_map[$nline] = true; if ($oline > 1) { $cover_map[$oline - 1] = true; } $cover_map[$oline] = true; ++$nline; break; default: ++$oline; ++$nline; break; } } switch ($type) { case 'new': return $new_map; case 'old': return $old_map; case 'cover': return $cover_map; default: throw new Exception(pht("Unknown line change type '%s'.", $type)); } } public function setOldOffset($old_offset) { $this->oldOffset = $old_offset; return $this; } public function getOldOffset() { return $this->oldOffset; } public function setNewOffset($new_offset) { $this->newOffset = $new_offset; return $this; } public function getNewOffset() { return $this->newOffset; } public function setOldLength($old_length) { $this->oldLength = $old_length; return $this; } public function getOldLength() { return $this->oldLength; } public function setNewLength($new_length) { $this->newLength = $new_length; return $this; } public function getNewLength() { return $this->newLength; } public function setAddLines($add_lines) { $this->addLines = $add_lines; return $this; } public function getAddLines() { return $this->addLines; } public function setDelLines($del_lines) { $this->delLines = $del_lines; return $this; } public function getDelLines() { return $this->delLines; } public function setCorpus($corpus) { $this->corpus = $corpus; return $this; } public function getCorpus() { return $this->corpus; } public function setIsMissingOldNewline($missing) { $this->isMissingOldNewline = (bool)$missing; return $this; } public function getIsMissingOldNewline() { return $this->isMissingOldNewline; } public function setIsMissingNewNewline($missing) { $this->isMissingNewNewline = (bool)$missing; return $this; } public function getIsMissingNewNewline() { return $this->isMissingNewNewline; } } diff --git a/src/repository/api/ArcanistRepositoryAPI.php b/src/repository/api/ArcanistRepositoryAPI.php index 56c7a931..292a7d32 100644 --- a/src/repository/api/ArcanistRepositoryAPI.php +++ b/src/repository/api/ArcanistRepositoryAPI.php @@ -1,660 +1,660 @@ diffLinesOfContext; } public function setDiffLinesOfContext($lines) { $this->diffLinesOfContext = $lines; return $this; } public function getWorkingCopyIdentity() { return $this->configurationManager->getWorkingCopyIdentity(); } public function getConfigurationManager() { return $this->configurationManager; } public static function newAPIFromConfigurationManager( ArcanistConfigurationManager $configuration_manager) { $working_copy = $configuration_manager->getWorkingCopyIdentity(); if (!$working_copy) { throw new Exception( pht( 'Trying to create a %s without a working copy!', __CLASS__)); } $root = $working_copy->getProjectRoot(); switch ($working_copy->getVCSType()) { case 'svn': $api = new ArcanistSubversionAPI($root); break; case 'hg': $api = new ArcanistMercurialAPI($root); break; case 'git': $api = new ArcanistGitAPI($root); break; default: throw new Exception( pht( 'The current working directory is not part of a working copy for '. 'a supported version control system (Git, Subversion or '. 'Mercurial).')); } $api->configurationManager = $configuration_manager; return $api; } public function __construct($path) { $this->path = $path; } public function getPath($to_file = null) { if ($to_file !== null) { return $this->path.DIRECTORY_SEPARATOR. ltrim($to_file, DIRECTORY_SEPARATOR); } else { return $this->path.DIRECTORY_SEPARATOR; } } /* -( Path Status )-------------------------------------------------------- */ abstract protected function buildUncommittedStatus(); abstract protected function buildCommitRangeStatus(); /** * Get a list of uncommitted paths in the working copy that have been changed * or are affected by other status effects, like conflicts or untracked * files. * * Convenience methods @{method:getUntrackedChanges}, * @{method:getUnstagedChanges}, @{method:getUncommittedChanges}, * @{method:getMergeConflicts}, and @{method:getIncompleteChanges} allow * simpler selection of paths in a specific state. * * This method returns a map of paths to bitmasks with status, using * `FLAG_` constants. For example: * * array( * 'some/uncommitted/file.txt' => ArcanistRepositoryAPI::FLAG_UNSTAGED, * ); * * A file may be in several states. Not all states are possible with all * version control systems. * * @return map Map of paths, see above. * @task status */ final public function getUncommittedStatus() { if ($this->uncommittedStatusCache === null) { $status = $this->buildUncommittedStatus(); ksort($status); $this->uncommittedStatusCache = $status; } return $this->uncommittedStatusCache; } /** * @task status */ final public function getUntrackedChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_UNTRACKED); } /** * @task status */ final public function getUnstagedChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_UNSTAGED); } /** * @task status */ final public function getUncommittedChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_UNCOMMITTED); } /** * @task status */ final public function getMergeConflicts() { return $this->getUncommittedPathsWithMask(self::FLAG_CONFLICT); } /** * @task status */ final public function getIncompleteChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_INCOMPLETE); } /** * @task status */ final public function getMissingChanges() { return $this->getUncommittedPathsWithMask(self::FLAG_MISSING); } /** * @task status */ private function getUncommittedPathsWithMask($mask) { $match = array(); foreach ($this->getUncommittedStatus() as $path => $flags) { if ($flags & $mask) { $match[] = $path; } } return $match; } /** * Get a list of paths affected by the commits in the current commit range. * * See @{method:getUncommittedStatus} for a description of the return value. * * @return map Map from paths to status. * @task status */ final public function getCommitRangeStatus() { if ($this->commitRangeStatusCache === null) { $status = $this->buildCommitRangeStatus(); ksort($status); $this->commitRangeStatusCache = $status; } return $this->commitRangeStatusCache; } /** * Get a list of paths affected by commits in the current commit range, or * uncommitted changes in the working copy. See @{method:getUncommittedStatus} * or @{method:getCommitRangeStatus} to retrieve smaller parts of the status. * * See @{method:getUncommittedStatus} for a description of the return value. * * @return map Map from paths to status. * @task status */ final public function getWorkingCopyStatus() { $range_status = $this->getCommitRangeStatus(); $uncommitted_status = $this->getUncommittedStatus(); $result = new PhutilArrayWithDefaultValue($range_status); foreach ($uncommitted_status as $path => $mask) { $result[$path] |= $mask; } $result = $result->toArray(); ksort($result); return $result; } /** * Drops caches after changes to the working copy. By default, some queries * against the working copy are cached. They * * @return this * @task status */ final public function reloadWorkingCopy() { $this->uncommittedStatusCache = null; $this->commitRangeStatusCache = null; $this->didReloadWorkingCopy(); $this->reloadCommitRange(); return $this; } /** * Hook for implementations to dirty working copy caches after the working * copy has been updated. * * @return this * @task status */ protected function didReloadWorkingCopy() { return; } /** * Fetches the original file data for each path provided. * * @return map Map from path to file data. */ public function getBulkOriginalFileData($paths) { $filedata = array(); foreach ($paths as $path) { $filedata[$path] = $this->getOriginalFileData($path); } return $filedata; } /** * Fetches the current file data for each path provided. * * @return map Map from path to file data. */ public function getBulkCurrentFileData($paths) { $filedata = array(); foreach ($paths as $path) { $filedata[$path] = $this->getCurrentFileData($path); } return $filedata; } /** * @return Traversable */ abstract public function getAllFiles(); abstract public function getBlame($path); abstract public function getRawDiffText($path); abstract public function getOriginalFileData($path); abstract public function getCurrentFileData($path); abstract public function getLocalCommitInformation(); abstract public function getSourceControlBaseRevision(); abstract public function getCanonicalRevisionName($string); abstract public function getBranchName(); abstract public function getSourceControlPath(); abstract public function isHistoryDefaultImmutable(); abstract public function supportsAmend(); abstract public function getWorkingCopyRevision(); abstract public function updateWorkingCopy(); abstract public function getMetadataPath(); abstract public function loadWorkingCopyDifferentialRevisions( ConduitClient $conduit, array $query); abstract public function getRemoteURI(); public function getUnderlyingWorkingCopyRevision() { return $this->getWorkingCopyRevision(); } public function getChangedFiles($since_commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getAuthor() { throw new ArcanistCapabilityNotSupportedException($this); } public function addToCommit(array $paths) { throw new ArcanistCapabilityNotSupportedException($this); } abstract public function supportsLocalCommits(); public function doCommit($message) { throw new ArcanistCapabilityNotSupportedException($this); } public function amendCommit($message = null) { throw new ArcanistCapabilityNotSupportedException($this); } public function getAllBranches() { // TODO: Implement for Mercurial/SVN and make abstract. return array(); } public function hasLocalCommit($commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getCommitMessage($commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getCommitSummary($commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getAllLocalChanges() { throw new ArcanistCapabilityNotSupportedException($this); } abstract public function supportsLocalBranchMerge(); public function performLocalBranchMerge($branch, $message) { throw new ArcanistCapabilityNotSupportedException($this); } public function getFinalizedRevisionMessage() { throw new ArcanistCapabilityNotSupportedException($this); } public function execxLocal($pattern /* , ... */) { $args = func_get_args(); return $this->buildLocalFuture($args)->resolvex(); } public function execManualLocal($pattern /* , ... */) { $args = func_get_args(); return $this->buildLocalFuture($args)->resolve(); } public function execFutureLocal($pattern /* , ... */) { $args = func_get_args(); return $this->buildLocalFuture($args); } abstract protected function buildLocalFuture(array $argv); public function canStashChanges() { return false; } public function stashChanges() { throw new ArcanistCapabilityNotSupportedException($this); } public function unstashChanges() { throw new ArcanistCapabilityNotSupportedException($this); } /* -( 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 */ public function readScratchFile($path) { $full_path = $this->getScratchFilePath($path); if (!$full_path) { return false; } if (!Filesystem::pathExists($full_path)) { return false; } try { $result = Filesystem::readFile($full_path); } catch (FilesystemException $ex) { return false; } return $result; } /** * 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 */ public function writeScratchFile($path, $data) { $dir = $this->getScratchFilePath(''); if (!$dir) { return false; } if (!Filesystem::pathExists($dir)) { try { Filesystem::createDirectory($dir); } catch (Exception $ex) { return false; } } try { Filesystem::writeFile($this->getScratchFilePath($path), $data); } catch (FilesystemException $ex) { return false; } return true; } /** * Try to remove a scratch file. * * @param string Scratch file name to remove. * @return bool True if the file was removed successfully. * @task scratch */ public function removeScratchFile($path) { $full_path = $this->getScratchFilePath($path); if (!$full_path) { return false; } try { Filesystem::remove($full_path); } catch (FilesystemException $ex) { return false; } return true; } /** * Get a human-readable description of the scratch file location. * * @param string Scratch file name. * @return mixed String, or false on failure. * @task scratch */ public function getReadableScratchFilePath($path) { $full_path = $this->getScratchFilePath($path); if ($full_path) { return Filesystem::readablePath( $full_path, $this->getPath()); } else { return false; } } /** * Get the path to a scratch file, if possible. * * @param string Scratch file name. * @return mixed File path, or false on failure. * @task scratch */ public function getScratchFilePath($path) { $new_scratch_path = Filesystem::resolvePath( 'arc', $this->getMetadataPath()); static $checked = false; if (!$checked) { $checked = true; $old_scratch_path = $this->getPath('.arc'); // we only want to do the migration once // unfortunately, people have checked in .arc directories which // means that the old one may get recreated after we delete it if (Filesystem::pathExists($old_scratch_path) && !Filesystem::pathExists($new_scratch_path)) { Filesystem::createDirectory($new_scratch_path); $existing_files = Filesystem::listDirectory($old_scratch_path, true); foreach ($existing_files as $file) { $new_path = Filesystem::resolvePath($file, $new_scratch_path); $old_path = Filesystem::resolvePath($file, $old_scratch_path); Filesystem::writeFile( $new_path, Filesystem::readFile($old_path)); } Filesystem::remove($old_scratch_path); } } return Filesystem::resolvePath($path, $new_scratch_path); } /* -( Base Commits )------------------------------------------------------- */ abstract public function supportsCommitRanges(); final public function setBaseCommit($symbolic_commit) { if (!$this->supportsCommitRanges()) { throw new ArcanistCapabilityNotSupportedException($this); } $this->symbolicBaseCommit = $symbolic_commit; $this->reloadCommitRange(); return $this; } public function setHeadCommit($symbolic_commit) { throw new ArcanistCapabilityNotSupportedException($this); } final public function getBaseCommit() { if (!$this->supportsCommitRanges()) { throw new ArcanistCapabilityNotSupportedException($this); } if ($this->resolvedBaseCommit === null) { $commit = $this->buildBaseCommit($this->symbolicBaseCommit); $this->resolvedBaseCommit = $commit; } return $this->resolvedBaseCommit; } public function getHeadCommit() { throw new ArcanistCapabilityNotSupportedException($this); } final public function reloadCommitRange() { $this->resolvedBaseCommit = null; $this->baseCommitExplanation = null; $this->didReloadCommitRange(); return $this; } protected function didReloadCommitRange() { return; } protected function buildBaseCommit($symbolic_commit) { throw new ArcanistCapabilityNotSupportedException($this); } public function getBaseCommitExplanation() { return $this->baseCommitExplanation; } public function setBaseCommitExplanation($explanation) { $this->baseCommitExplanation = $explanation; return $this; } public function resolveBaseCommitRule($rule, $source) { return null; } public function setBaseCommitArgumentRules($base_commit_argument_rules) { $this->baseCommitArgumentRules = $base_commit_argument_rules; return $this; } public function getBaseCommitArgumentRules() { return $this->baseCommitArgumentRules; } public function resolveBaseCommit() { $base_commit_rules = array( 'runtime' => $this->getBaseCommitArgumentRules(), 'local' => '', 'project' => '', 'user' => '', 'system' => '', ); $all_sources = $this->configurationManager->getConfigFromAllSources('base'); $base_commit_rules = $all_sources + $base_commit_rules; $parser = new ArcanistBaseCommitParser($this); $commit = $parser->resolveBaseCommit($base_commit_rules); return $commit; } public function getRepositoryUUID() { return null; } } diff --git a/src/repository/parser/ArcanistMercurialParser.php b/src/repository/parser/ArcanistMercurialParser.php index b9044500..8317cc7c 100644 --- a/src/repository/parser/ArcanistMercurialParser.php +++ b/src/repository/parser/ArcanistMercurialParser.php @@ -1,236 +1,236 @@ $flags, 'from' => null, ); $last_path = $path; } return $result; } /** * Parse the output of "hg status". This provides only basic information, you * can get more detailed information by invoking * @{method:parseMercurialStatusDetails}. * * @param string The stdout from running an "hg status" command. * @return dict Map of paths to ArcanistRepositoryAPI status flags. * @task parse */ public static function parseMercurialStatus($stdout) { $result = self::parseMercurialStatusDetails($stdout); return ipull($result, 'flags'); } /** * Parse the output of "hg log". This also parses "hg outgoing", "hg parents", * and other similar commands. This assumes "--style default". * * @param string The stdout from running an "hg log" command. * @return list List of dictionaries with commit information. * @task parse */ public static function parseMercurialLog($stdout) { $result = array(); $stdout = trim($stdout); if (!strlen($stdout)) { return $result; } $chunks = explode("\n\n", $stdout); foreach ($chunks as $chunk) { $commit = array(); $lines = explode("\n", $chunk); foreach ($lines as $line) { if (preg_match('/^(comparing with|searching for changes)/', $line)) { // These are sent to stdout when you run "hg outgoing" although the // format is otherwise identical to "hg log". continue; } if (preg_match('/^remote:/', $line)) { // This indicates remote error in "hg outgoing". continue; } list($name, $value) = explode(':', $line, 2); $value = trim($value); switch ($name) { case 'user': $commit['user'] = $value; break; case 'date': $commit['date'] = strtotime($value); break; case 'summary': $commit['summary'] = $value; break; case 'changeset': list($local, $rev) = explode(':', $value, 2); $commit['local'] = $local; $commit['rev'] = $rev; break; case 'parent': if (empty($commit['parents'])) { $commit['parents'] = array(); } list($local, $rev) = explode(':', $value, 2); $commit['parents'][] = array( 'local' => $local, 'rev' => $rev, ); break; case 'branch': $commit['branch'] = $value; break; case 'tag': $commit['tag'] = $value; break; case 'bookmark': $commit['bookmark'] = $value; break; default: throw new Exception( pht("Unknown Mercurial log field '%s'!", $name)); } } $result[] = $commit; } return $result; } /** * Parse the output of "hg branches". * * @param string The stdout from running an "hg branches" command. * @return list A list of dictionaries with branch information. * @task parse */ public static function parseMercurialBranches($stdout) { $stdout = rtrim($stdout, "\n"); if (!strlen($stdout)) { // No branches; commonly, this occurs in a newly initialized repository. return array(); } $lines = explode("\n", $stdout); $branches = array(); foreach ($lines as $line) { $matches = null; // Output of "hg branches" normally looks like: // // default 15101:a21ccf4412d5 // // ...but may also have human-readable cues like: // // stable 15095:ec222a29bdf0 (inactive) // // See the unit tests for more examples. $regexp = '/^(\S+(?:\s+\S+)*)\s+(\d+):([a-f0-9]+)(\s+\\(inactive\\))?$/'; if (!preg_match($regexp, $line, $matches)) { throw new Exception( pht( "Failed to parse '%s' output: %s", 'hg branches', $line)); } $branches[$matches[1]] = array( 'local' => $matches[2], 'rev' => $matches[3], ); } return $branches; } } diff --git a/src/unit/ArcanistUnitTestResult.php b/src/unit/ArcanistUnitTestResult.php index 0a8d6e63..74805581 100644 --- a/src/unit/ArcanistUnitTestResult.php +++ b/src/unit/ArcanistUnitTestResult.php @@ -1,136 +1,136 @@ namespace = $namespace; return $this; } public function getNamespace() { return $this->namespace; } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function setLink($link) { $this->link = $link; return $this; } public function getLink() { return $this->link; } public function setResult($result) { $this->result = $result; return $this; } public function getResult() { return $this->result; } public function setDuration($duration) { $this->duration = $duration; return $this; } public function getDuration() { return $this->duration; } public function setUserData($user_data) { $this->userData = $user_data; return $this; } public function getUserData() { return $this->userData; } /** * "extra data" allows an implementation to store additional key/value * metadata along with the result of the test run. */ public function setExtraData(array $extra_data = null) { $this->extraData = $extra_data; return $this; } public function getExtraData() { return $this->extraData; } public function setCoverage($coverage) { $this->coverage = $coverage; return $this; } public function getCoverage() { return $this->coverage; } /** * Merge several coverage reports into a comprehensive coverage report. * * @param list List of coverage report strings. * @return string Cumulative coverage report. */ public static function mergeCoverage(array $coverage) { if (empty($coverage)) { return null; } $base = reset($coverage); foreach ($coverage as $more_coverage) { $len = min(strlen($base), strlen($more_coverage)); for ($ii = 0; $ii < $len; $ii++) { if ($more_coverage[$ii] == 'C') { $base[$ii] = 'C'; } } } return $base; } public function toDictionary() { return array( 'namespace' => $this->getNamespace(), 'name' => $this->getName(), 'link' => $this->getLink(), 'result' => $this->getResult(), 'duration' => $this->getDuration(), 'extra' => $this->getExtraData(), 'userData' => $this->getUserData(), 'coverage' => $this->getCoverage(), ); } } diff --git a/src/unit/engine/ArcanistUnitTestEngine.php b/src/unit/engine/ArcanistUnitTestEngine.php index 95d67313..9783ce26 100644 --- a/src/unit/engine/ArcanistUnitTestEngine.php +++ b/src/unit/engine/ArcanistUnitTestEngine.php @@ -1,113 +1,114 @@ supportsRunAllTests() && $run_all_tests) { throw new Exception( pht( "Engine '%s' does not support %s.", get_class($this), '--everything')); } $this->runAllTests = $run_all_tests; return $this; } final public function getRunAllTests() { return $this->runAllTests; } protected function supportsRunAllTests() { return false; } final public function setConfigurationManager( ArcanistConfigurationManager $configuration_manager) { $this->configurationManager = $configuration_manager; return $this; } final public function getConfigurationManager() { return $this->configurationManager; } final public function setWorkingCopy( ArcanistWorkingCopyIdentity $working_copy) { $this->workingCopy = $working_copy; return $this; } final public function getWorkingCopy() { return $this->workingCopy; } final public function setPaths(array $paths) { $this->paths = $paths; return $this; } final public function getPaths() { return $this->paths; } final public function setArguments(array $arguments) { $this->arguments = $arguments; return $this; } final public function getArgument($key, $default = null) { return idx($this->arguments, $key, $default); } final public function setEnableAsyncTests($enable_async_tests) { $this->enableAsyncTests = $enable_async_tests; return $this; } final public function getEnableAsyncTests() { return $this->enableAsyncTests; } final public function setEnableCoverage($enable_coverage) { $this->enableCoverage = $enable_coverage; return $this; } final public function getEnableCoverage() { return $this->enableCoverage; } final public function setRenderer(ArcanistUnitRenderer $renderer) { $this->renderer = $renderer; return $this; } abstract public function run(); /** * Modify the return value of this function in the child class, if you do * not need to echo the test results after all the tests have been run. This * is the case for example when the child class prints the tests results * while the tests are running. */ public function shouldEchoTestResults() { return true; } } diff --git a/src/unit/engine/phutil/PhutilTestCase.php b/src/unit/engine/phutil/PhutilTestCase.php index 544384fc..902586ba 100644 --- a/src/unit/engine/phutil/PhutilTestCase.php +++ b/src/unit/engine/phutil/PhutilTestCase.php @@ -1,745 +1,745 @@ assertions++; return; } $this->failAssertionWithExpectedValue('false', $result, $message); } /** * Assert that a value is `true`, strictly. The test fails if it is not. * * @param wild The empirically derived value, generated by executing the * test. * @param string A human-readable description of what these values represent, * and particularly of what a discrepancy means. * * @return void * @task assert */ final protected function assertTrue($result, $message = null) { if ($result === true) { $this->assertions++; return; } $this->failAssertionWithExpectedValue('true', $result, $message); } /** * Assert that two values are equal, strictly. The test fails if they are not. * * NOTE: This method uses PHP's strict equality test operator (`===`) to * compare values. This means values and types must be equal, key order must * be identical in arrays, and objects must be referentially identical. * * @param wild The theoretically expected value, generated by careful * reasoning about the properties of the system. * @param wild The empirically derived value, generated by executing the * test. * @param string A human-readable description of what these values represent, * and particularly of what a discrepancy means. * * @return void * @task assert */ final protected function assertEqual($expect, $result, $message = null) { if ($expect === $result) { $this->assertions++; return; } $expect = PhutilReadableSerializer::printableValue($expect); $result = PhutilReadableSerializer::printableValue($result); $caller = self::getCallerInfo(); $file = $caller['file']; $line = $caller['line']; if ($message !== null) { $output = pht( 'Assertion failed, expected values to be equal (at %s:%d): %s', $file, $line, $message); } else { $output = pht( 'Assertion failed, expected values to be equal (at %s:%d).', $file, $line); } $output .= "\n"; if (strpos($expect, "\n") === false && strpos($result, "\n") === false) { $output .= pht("Expected: %s\n Actual: %s", $expect, $result); } else { $output .= pht( "Expected vs Actual Output Diff\n%s", ArcanistDiffUtils::renderDifferences( $expect, $result, $lines = 0xFFFF)); } $this->failTest($output); throw new PhutilTestTerminatedException($output); } /** * Assert an unconditional failure. This is just a convenience method that * better indicates intent than using dummy values with assertEqual(). This * causes test failure. * * @param string Human-readable description of the reason for test failure. * @return void * @task assert */ final protected function assertFailure($message) { $this->failTest($message); throw new PhutilTestTerminatedException($message); } /** * End this test by asserting that the test should be skipped for some * reason. * * @param string Reason for skipping this test. * @return void * @task assert */ final protected function assertSkipped($message) { $this->skipTest($message); throw new PhutilTestSkippedException($message); } /* -( Exception Handling )------------------------------------------------- */ /** * This simplest way to assert exceptions are thrown. * * @param exception The expected exception. * @param callable The thing which throws the exception. * * @return void * @task exceptions */ final protected function assertException( $expected_exception_class, $callable) { $this->tryTestCases( array('assertException' => array()), array(false), $callable, $expected_exception_class); } /** * Straightforward method for writing unit tests which check if some block of * code throws an exception. For example, this allows you to test the * exception behavior of ##is_a_fruit()## on various inputs: * * public function testFruit() { * $this->tryTestCases( * array( * 'apple is a fruit' => new Apple(), * 'rock is not a fruit' => new Rock(), * ), * array( * true, * false, * ), * array($this, 'tryIsAFruit'), * 'NotAFruitException'); * } * * protected function tryIsAFruit($input) { * is_a_fruit($input); * } * * @param map Map of test case labels to test case inputs. * @param list List of expected results, true to indicate that the case * is expected to succeed and false to indicate that the case * is expected to throw. * @param callable Callback to invoke for each test case. * @param string Optional exception class to catch, defaults to * 'Exception'. * @return void * @task exceptions */ final protected function tryTestCases( array $inputs, array $expect, $callable, $exception_class = 'Exception') { if (count($inputs) !== count($expect)) { $this->assertFailure( pht('Input and expectations must have the same number of values.')); } $labels = array_keys($inputs); $inputs = array_values($inputs); $expecting = array_values($expect); foreach ($inputs as $idx => $input) { $expect = $expecting[$idx]; $label = $labels[$idx]; $caught = null; try { call_user_func($callable, $input); } catch (Exception $ex) { if ($ex instanceof PhutilTestTerminatedException) { throw $ex; } if (!($ex instanceof $exception_class)) { throw $ex; } $caught = $ex; } $actual = !($caught instanceof Exception); if ($expect === $actual) { if ($expect) { $message = pht("Test case '%s' did not throw, as expected.", $label); } else { $message = pht("Test case '%s' threw, as expected.", $label); } } else { if ($expect) { $message = pht( "Test case '%s' was expected to succeed, but it ". "raised an exception of class %s with message: %s", $label, get_class($ex), $ex->getMessage()); } else { $message = pht( "Test case '%s' was expected to raise an ". "exception, but it did not throw anything.", $label); } } $this->assertEqual($expect, $actual, $message); } } /** * Convenience wrapper around @{method:tryTestCases} for cases where your * inputs are scalar. For example: * * public function testFruit() { * $this->tryTestCaseMap( * array( * 'apple' => true, * 'rock' => false, * ), * array($this, 'tryIsAFruit'), * 'NotAFruitException'); * } * * protected function tryIsAFruit($input) { * is_a_fruit($input); * } * * For cases where your inputs are not scalar, use @{method:tryTestCases}. * * @param map Map of scalar test inputs to expected success (true * expects success, false expects an exception). * @param callable Callback to invoke for each test case. * @param string Optional exception class to catch, defaults to * 'Exception'. * @return void * @task exceptions */ final protected function tryTestCaseMap( array $map, $callable, $exception_class = 'Exception') { return $this->tryTestCases( array_fuse(array_keys($map)), array_values($map), $callable, $exception_class); } /* -( Hooks for Setup and Teardown )--------------------------------------- */ /** * This hook is invoked once, before any tests in this class are run. It * gives you an opportunity to perform setup steps for the entire class. * * @return void * @task hook */ protected function willRunTests() { return; } /** * This hook is invoked once, after any tests in this class are run. It gives * you an opportunity to perform teardown steps for the entire class. * * @return void * @task hook */ protected function didRunTests() { return; } /** * This hook is invoked once per test, before the test method is invoked. * * @param string Method name of the test which will be invoked. * @return void * @task hook */ protected function willRunOneTest($test_method_name) { return; } /** * This hook is invoked once per test, after the test method is invoked. * * @param string Method name of the test which was invoked. * @return void * @task hook */ protected function didRunOneTest($test_method_name) { return; } /** * This hook is invoked once, before any test cases execute. It gives you * an opportunity to perform setup steps for the entire suite of test cases. * * @param list List of test cases to be run. * @return void * @task hook */ public function willRunTestCases(array $test_cases) { return; } /** * This hook is invoked once, after all test cases execute. * * @param list List of test cases that ran. * @return void * @task hook */ public function didRunTestCases(array $test_cases) { return; } /* -( Internals )---------------------------------------------------------- */ /** * Construct a new test case. This method is ##final##, use willRunTests() to * provide test-wide setup logic. * * @task internal */ final public function __construct() {} /** * Mark the currently-running test as a failure. * * @param string Human-readable description of problems. * @return void * * @task internal */ final private function failTest($reason) { $this->resultTest(ArcanistUnitTestResult::RESULT_FAIL, $reason); } /** * This was a triumph. I'm making a note here: HUGE SUCCESS. * * @param string Human-readable overstatement of satisfaction. * @return void * * @task internal */ final private function passTest($reason) { $this->resultTest(ArcanistUnitTestResult::RESULT_PASS, $reason); } /** * Mark the current running test as skipped. * * @param string Description for why this test was skipped. * @return void * @task internal */ final private function skipTest($reason) { $this->resultTest(ArcanistUnitTestResult::RESULT_SKIP, $reason); } final private function resultTest($test_result, $reason) { $coverage = $this->endCoverage(); $result = new ArcanistUnitTestResult(); $result->setCoverage($coverage); $result->setNamespace(get_class($this)); $result->setName($this->runningTest); $result->setLink($this->getLink($this->runningTest)); $result->setResult($test_result); $result->setDuration(microtime(true) - $this->testStartTime); $result->setUserData($reason); $this->results[] = $result; if ($this->renderer) { echo $this->renderer->renderUnitResult($result); } } /** * Execute the tests in this test case. You should not call this directly; * use @{class:PhutilUnitTestEngine} to orchestrate test execution. * * @return void * @task internal */ final public function run() { $this->results = array(); $reflection = new ReflectionClass($this); $methods = $reflection->getMethods(); // Try to ensure that poorly-written tests which depend on execution order // (and are thus not properly isolated) will fail. shuffle($methods); $this->willRunTests(); foreach ($methods as $method) { $name = $method->getName(); if (preg_match('/^test/', $name)) { $this->runningTest = $name; $this->assertions = 0; $this->testStartTime = microtime(true); try { $this->willRunOneTest($name); $this->beginCoverage(); $exceptions = array(); try { call_user_func_array( array($this, $name), array()); $this->passTest(pht('%d assertion(s) passed.', $this->assertions)); } catch (Exception $ex) { $exceptions['Execution'] = $ex; } try { $this->didRunOneTest($name); } catch (Exception $ex) { $exceptions['Shutdown'] = $ex; } if ($exceptions) { if (count($exceptions) == 1) { throw head($exceptions); } else { throw new PhutilAggregateException( pht('Multiple exceptions were raised during test execution.'), $exceptions); } } if (!$this->assertions) { $this->failTest( pht( 'This test case made no assertions. Test cases must make at '. 'least one assertion.')); } } catch (PhutilTestTerminatedException $ex) { // Continue with the next test. } catch (PhutilTestSkippedException $ex) { // Continue with the next test. } catch (Exception $ex) { $ex_class = get_class($ex); $ex_message = $ex->getMessage(); $ex_trace = $ex->getTraceAsString(); $message = sprintf( "%s (%s): %s\n%s", pht('EXCEPTION'), $ex_class, $ex_message, $ex_trace); $this->failTest($message); } } } $this->didRunTests(); return $this->results; } final public function setEnableCoverage($enable_coverage) { $this->enableCoverage = $enable_coverage; return $this; } /** * @phutil-external-symbol function xdebug_start_code_coverage */ final private function beginCoverage() { if (!$this->enableCoverage) { return; } $this->assertCoverageAvailable(); xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE); } /** * @phutil-external-symbol function xdebug_get_code_coverage * @phutil-external-symbol function xdebug_stop_code_coverage */ final private function endCoverage() { if (!$this->enableCoverage) { return; } $result = xdebug_get_code_coverage(); xdebug_stop_code_coverage($cleanup = false); $coverage = array(); foreach ($result as $file => $report) { $project_root = $this->getProjectRoot(); if (strncmp($file, $project_root, strlen($project_root))) { continue; } $max = max(array_keys($report)); $str = ''; for ($ii = 1; $ii <= $max; $ii++) { $c = idx($report, $ii); if ($c === -1) { $str .= 'U'; // Un-covered. } else if ($c === -2) { // TODO: This indicates "unreachable", but it flags the closing braces // of functions which end in "return", which is super ridiculous. Just // ignore it for now. // // See http://bugs.xdebug.org/view.php?id=1041 $str .= 'N'; // Not executable. } else if ($c === 1) { $str .= 'C'; // Covered. } else { $str .= 'N'; // Not executable. } } $coverage[substr($file, strlen($project_root) + 1)] = $str; } // Only keep coverage information for files modified by the change. In // the case of --everything, we won't have paths, so just return all the // coverage data. if ($this->paths) { $coverage = array_select_keys($coverage, $this->paths); } return $coverage; } final private function assertCoverageAvailable() { if (!function_exists('xdebug_start_code_coverage')) { throw new Exception( pht("You've enabled code coverage but XDebug is not installed.")); } } final public function getWorkingCopy() { return $this->workingCopy; } final public function setWorkingCopy( ArcanistWorkingCopyIdentity $working_copy) { $this->workingCopy = $working_copy; return $this; } final public function getProjectRoot() { $working_copy = $this->getWorkingCopy(); if (!$working_copy) { throw new PhutilInvalidStateException('setWorkingCopy'); } return $working_copy->getProjectRoot(); } final public function setPaths(array $paths) { $this->paths = $paths; return $this; } final protected function getLink($method) { $base_uri = $this ->getWorkingCopy() ->getProjectConfig('phabricator.uri'); $uri = id(new PhutilURI($base_uri)) ->setPath("/diffusion/symbol/{$method}/") ->setQueryParam('context', get_class($this)) ->setQueryParam('jump', 'true') ->setQueryParam('lang', 'php'); return (string)$uri; } final public function setRenderer(ArcanistUnitRenderer $renderer) { $this->renderer = $renderer; return $this; } /** * Returns info about the caller function. * * @return map */ final private static function getCallerInfo() { $callee = array(); $caller = array(); $seen = false; foreach (array_slice(debug_backtrace(), 1) as $location) { $function = idx($location, 'function'); if (!$seen && preg_match('/^assert[A-Z]/', $function)) { $seen = true; $caller = $location; } else if ($seen && !preg_match('/^assert[A-Z]/', $function)) { $callee = $location; break; } } return array( 'file' => basename(idx($caller, 'file')), 'line' => idx($caller, 'line'), 'function' => idx($callee, 'function'), 'class' => idx($callee, 'class'), 'object' => idx($caller, 'object'), 'type' => idx($callee, 'type'), 'args' => idx($caller, 'args'), ); } /** * Fail an assertion which checks that some result is equal to a specific * value, like 'true' or 'false'. This prints a readable error message and * fails the current test. * * This method throws and does not return. * * @param string Human readable description of the expected value. * @param string The actual value. * @param string|null Optional assertion message. * @return void * @task internal */ private function failAssertionWithExpectedValue( $expect_description, $actual_result, $message) { $caller = self::getCallerInfo(); $file = $caller['file']; $line = $caller['line']; if ($message !== null) { $description = pht( "Assertion failed, expected '%s' (at %s:%d): %s", $expect_description, $file, $line, $message); } else { $description = pht( "Assertion failed, expected '%s' (at %s:%d).", $expect_description, $file, $line); } $actual_result = PhutilReadableSerializer::printableValue($actual_result); $header = pht('ACTUAL VALUE'); $output = $description."\n\n".$header."\n".$actual_result; $this->failTest($output); throw new PhutilTestTerminatedException($output); } } diff --git a/src/unit/parser/ArcanistTestResultParser.php b/src/unit/parser/ArcanistTestResultParser.php index 1cb0639a..d19bde59 100644 --- a/src/unit/parser/ArcanistTestResultParser.php +++ b/src/unit/parser/ArcanistTestResultParser.php @@ -1,48 +1,48 @@ enableCoverage = $enable_coverage; return $this; } public function setProjectRoot($project_root) { $this->projectRoot = $project_root; return $this; } public function setCoverageFile($coverage_file) { $this->coverageFile = $coverage_file; return $this; } public function setAffectedTests($affected_tests) { $this->affectedTests = $affected_tests; return $this; } public function setStderr($stderr) { $this->stderr = $stderr; return $this; } /** * Parse test results from provided input and return an array of * @{class:ArcanistUnitTestResult}. * * @param string Path to test. * @param string String containing test results. * @return array */ abstract public function parseTestResults($path, $test_results); } diff --git a/src/unit/parser/ArcanistXUnitTestResultParser.php b/src/unit/parser/ArcanistXUnitTestResultParser.php index 3f1f447d..e6c50d47 100644 --- a/src/unit/parser/ArcanistXUnitTestResultParser.php +++ b/src/unit/parser/ArcanistXUnitTestResultParser.php @@ -1,101 +1,101 @@ loadXML($test_results); if (!$load_success) { $input_start = id(new PhutilUTF8StringTruncator()) ->setMaximumGlyphs(150) ->truncateString($test_results); throw new Exception( sprintf( "%s\n\n%s", pht('Failed to load XUnit report; Input starts with:'), $input_start)); } $results = array(); $testcases = $xunit_dom->getElementsByTagName('testcase'); foreach ($testcases as $testcase) { $classname = $testcase->getAttribute('classname'); $name = $testcase->getAttribute('name'); $time = $testcase->getAttribute('time'); $status = ArcanistUnitTestResult::RESULT_PASS; $user_data = ''; // A skipped test is a test which was ignored using framework // mechanisms (e.g. @skip decorator) $skipped = $testcase->getElementsByTagName('skipped'); if ($skipped->length > 0) { $status = ArcanistUnitTestResult::RESULT_SKIP; $messages = array(); for ($ii = 0; $ii < $skipped->length; $ii++) { $messages[] = trim($skipped->item($ii)->nodeValue, " \n"); } $user_data .= implode("\n", $messages); } // Failure is a test which the code has explicitly failed by using // the mechanisms for that purpose. e.g., via an assertEquals $failures = $testcase->getElementsByTagName('failure'); if ($failures->length > 0) { $status = ArcanistUnitTestResult::RESULT_FAIL; $messages = array(); for ($ii = 0; $ii < $failures->length; $ii++) { $messages[] = trim($failures->item($ii)->nodeValue, " \n"); } $user_data .= implode("\n", $messages)."\n"; } // An errored test is one that had an unanticipated problem. e.g., an // unchecked throwable, or a problem with an implementation of the test. $errors = $testcase->getElementsByTagName('error'); if ($errors->length > 0) { $status = ArcanistUnitTestResult::RESULT_BROKEN; $messages = array(); for ($ii = 0; $ii < $errors->length; $ii++) { $messages[] = trim($errors->item($ii)->nodeValue, " \n"); } $user_data .= implode("\n", $messages)."\n"; } $result = new ArcanistUnitTestResult(); $result->setName($classname.'.'.$name); $result->setResult($status); $result->setDuration($time); $result->setUserData($user_data); $results[] = $result; } return $results; } } diff --git a/src/unit/renderer/ArcanistUnitRenderer.php b/src/unit/renderer/ArcanistUnitRenderer.php index 3f798e83..4dd936c3 100644 --- a/src/unit/renderer/ArcanistUnitRenderer.php +++ b/src/unit/renderer/ArcanistUnitRenderer.php @@ -1,6 +1,6 @@ $parent_path) { $try = array( 'git' => $parent_path.'/.git', 'hg' => $parent_path.'/.hg', 'svn' => $parent_path.'/.svn', ); foreach ($try as $vcs => $try_dir) { if (!Filesystem::pathExists($try_dir)) { continue; } // NOTE: We're distinguishing between the `$project_root` and the // `$vcs_root` because they may not be the same in Subversion. Normally, // they are identical. However, in Subversion, the `$vcs_root` is the // base directory of the working copy (the directory which has the // `.svn/` directory, after SVN 1.7), while the `$project_root` might // be any subdirectory of the `$vcs_root`: it's the the directory // closest to the current directory which contains a `.arcconfig`. $project_root = $parent_path; $vcs_root = $parent_path; $vcs_type = $vcs; if ($vcs == 'svn') { // For Subversion, we'll look for a ".arcconfig" file here or in // any subdirectory, starting at the deepest subdirectory. $config_paths = array_slice($paths, $path_key); $config_paths = array_reverse($config_paths); } else { // For Git and Mercurial, we'll only look for ".arcconfig" right here. $config_paths = array($parent_path); } break; } } $console = PhutilConsole::getConsole(); $looked_in = array(); foreach ($config_paths as $config_path) { $config_file = $config_path.'/.arcconfig'; $looked_in[] = $config_file; if (Filesystem::pathExists($config_file)) { // We always need to examine the filesystem to look for `.arcconfig` // so we can set the project root correctly. We might or might not // actually read the file: if the caller passed in configuration data, // we'll ignore the actual file contents. $project_root = $config_path; if ($config === null) { $console->writeLog( "%s\n", pht( 'Working Copy: Reading %s from "%s".', '.arcconfig', $config_file)); $config_data = Filesystem::readFile($config_file); $config = self::parseRawConfigFile($config_data, $config_file); } break; } } if ($config === null) { if ($looked_in) { $console->writeLog( "%s\n", pht( 'Working Copy: Unable to find %s in any of these locations: %s.', '.arcconfig', implode(', ', $looked_in))); } else { $console->writeLog( "%s\n", pht( 'Working Copy: No candidate locations for %s from '. 'this working directory.', '.arcconfig')); } $config = array(); } if ($project_root === null) { // We aren't in a working directory at all. This is fine if we're // running a command like "arc help". If we're running something that // requires a working directory, an exception will be raised a little // later on. $console->writeLog( "%s\n", pht('Working Copy: Path "%s" is not in any working copy.', $path)); return new ArcanistWorkingCopyIdentity($path, $config); } $console->writeLog( "%s\n", pht( 'Working Copy: Path "%s" is part of `%s` working copy "%s".', $path, $vcs_type, $vcs_root)); $console->writeLog( "%s\n", pht( 'Working Copy: Project root is at "%s".', $project_root)); $identity = new ArcanistWorkingCopyIdentity($project_root, $config); $identity->localMetaDir = $vcs_root.'/.'.$vcs_type; $identity->localConfig = $identity->readLocalArcConfig(); $identity->vcsType = $vcs_type; $identity->vcsRoot = $vcs_root; return $identity; } public static function newFromRootAndConfigFile( $root, $config_raw, $from_where) { if ($config_raw === null) { $config = array(); } else { $config = self::parseRawConfigFile($config_raw, $from_where); } return self::newFromPathWithConfig($root, $config); } private static function parseRawConfigFile($raw_config, $from_where) { try { return phutil_json_decode($raw_config); } catch (PhutilJSONParserException $ex) { throw new PhutilProxyException( pht("Unable to parse '%s' file '%s'.", '.arcconfig', $from_where), $ex); } } private function __construct($root, array $config) { $this->projectRoot = $root; $this->projectConfig = $config; } public function getProjectRoot() { return $this->projectRoot; } public function getProjectPath($to_file) { return $this->projectRoot.'/'.$to_file; } public function getVCSType() { return $this->vcsType; } public function getVCSRoot() { return $this->vcsRoot; } /* -( Config )------------------------------------------------------------- */ public function readProjectConfig() { return $this->projectConfig; } /** * Read a configuration directive from project configuration. This reads ONLY * permanent project configuration (i.e., ".arcconfig"), not other * configuration sources. See @{method:getConfigFromAnySource} to read from * user configuration. * * @param key Key to read. * @param wild Default value if key is not found. * @return wild Value, or default value if not found. * * @task config */ public function getProjectConfig($key, $default = null) { $settings = new ArcanistSettings(); $pval = idx($this->projectConfig, $key); // Test for older names in the per-project config only, since // they've only been used there. if ($pval === null) { $legacy = $settings->getLegacyName($key); if ($legacy) { $pval = $this->getProjectConfig($legacy); } } if ($pval === null) { $pval = $default; } else { $pval = $settings->willReadValue($key, $pval); } return $pval; } /** * Read a configuration directive from local configuration. This * reads ONLY the per-working copy configuration, * i.e. .(git|hg|svn)/arc/config, and not other configuration * sources. See @{method:getConfigFromAnySource} to read from any * config source or @{method:getProjectConfig} to read permanent * project-level config. * * @task config */ public function getLocalConfig($key, $default = null) { return idx($this->localConfig, $key, $default); } public function readLocalArcConfig() { if (strlen($this->localMetaDir)) { $local_path = Filesystem::resolvePath('arc/config', $this->localMetaDir); $console = PhutilConsole::getConsole(); if (Filesystem::pathExists($local_path)) { $console->writeLog( "%s\n", pht( 'Config: Reading local configuration file "%s"...', $local_path)); try { $json = Filesystem::readFile($local_path); return phutil_json_decode($json); } catch (PhutilJSONParserException $ex) { throw new PhutilProxyException( pht("Failed to parse '%s' as JSON.", $local_path), $ex); } } else { $console->writeLog( "%s\n", pht( 'Config: Did not find local configuration at "%s".', $local_path)); } } return array(); } public function writeLocalArcConfig(array $config) { $json_encoder = new PhutilJSON(); $json = $json_encoder->encodeFormatted($config); $dir = $this->localMetaDir; if (!strlen($dir)) { throw new Exception(pht('No working copy to write config into!')); } $local_dir = $dir.DIRECTORY_SEPARATOR.'arc'; if (!Filesystem::pathExists($local_dir)) { Filesystem::createDirectory($local_dir, 0755); } $config_file = $local_dir.DIRECTORY_SEPARATOR.'config'; Filesystem::writeFile($config_file, $json); } }