diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 6485c16c..b1a2955d 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1,1052 +1,1054 @@ 2, 'class' => array( 'AASTNode' => 'parser/aast/api/AASTNode.php', 'AASTNodeList' => 'parser/aast/api/AASTNodeList.php', 'AASTToken' => 'parser/aast/api/AASTToken.php', 'AASTTree' => 'parser/aast/api/AASTTree.php', 'AbstractDirectedGraph' => 'utils/AbstractDirectedGraph.php', 'AbstractDirectedGraphTestCase' => 'utils/__tests__/AbstractDirectedGraphTestCase.php', 'AphrontAccessDeniedQueryException' => 'aphront/storage/exception/AphrontAccessDeniedQueryException.php', 'AphrontBaseMySQLDatabaseConnection' => 'aphront/storage/connection/mysql/AphrontBaseMySQLDatabaseConnection.php', 'AphrontCharacterSetQueryException' => 'aphront/storage/exception/AphrontCharacterSetQueryException.php', 'AphrontConnectionLostQueryException' => 'aphront/storage/exception/AphrontConnectionLostQueryException.php', 'AphrontConnectionQueryException' => 'aphront/storage/exception/AphrontConnectionQueryException.php', 'AphrontCountQueryException' => 'aphront/storage/exception/AphrontCountQueryException.php', 'AphrontDatabaseConnection' => 'aphront/storage/connection/AphrontDatabaseConnection.php', 'AphrontDatabaseTransactionState' => 'aphront/storage/connection/AphrontDatabaseTransactionState.php', 'AphrontDeadlockQueryException' => 'aphront/storage/exception/AphrontDeadlockQueryException.php', 'AphrontDuplicateKeyQueryException' => 'aphront/storage/exception/AphrontDuplicateKeyQueryException.php', 'AphrontInvalidCredentialsQueryException' => 'aphront/storage/exception/AphrontInvalidCredentialsQueryException.php', 'AphrontIsolatedDatabaseConnection' => 'aphront/storage/connection/AphrontIsolatedDatabaseConnection.php', 'AphrontLockTimeoutQueryException' => 'aphront/storage/exception/AphrontLockTimeoutQueryException.php', 'AphrontMySQLDatabaseConnection' => 'aphront/storage/connection/mysql/AphrontMySQLDatabaseConnection.php', 'AphrontMySQLiDatabaseConnection' => 'aphront/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php', 'AphrontNotSupportedQueryException' => 'aphront/storage/exception/AphrontNotSupportedQueryException.php', 'AphrontObjectMissingQueryException' => 'aphront/storage/exception/AphrontObjectMissingQueryException.php', 'AphrontParameterQueryException' => 'aphront/storage/exception/AphrontParameterQueryException.php', 'AphrontQueryException' => 'aphront/storage/exception/AphrontQueryException.php', 'AphrontQueryTimeoutQueryException' => 'aphront/storage/exception/AphrontQueryTimeoutQueryException.php', 'AphrontRecoverableQueryException' => 'aphront/storage/exception/AphrontRecoverableQueryException.php', 'AphrontRequestStream' => 'aphront/requeststream/AphrontRequestStream.php', 'AphrontSchemaQueryException' => 'aphront/storage/exception/AphrontSchemaQueryException.php', 'AphrontScopedUnguardedWriteCapability' => 'aphront/writeguard/AphrontScopedUnguardedWriteCapability.php', 'AphrontWriteGuard' => 'aphront/writeguard/AphrontWriteGuard.php', 'BaseHTTPFuture' => 'future/http/BaseHTTPFuture.php', 'CaseInsensitiveArray' => 'utils/CaseInsensitiveArray.php', 'CaseInsensitiveArrayTestCase' => 'utils/__tests__/CaseInsensitiveArrayTestCase.php', 'CommandException' => 'future/exec/CommandException.php', 'ConduitClient' => 'conduit/ConduitClient.php', 'ConduitClientException' => 'conduit/ConduitClientException.php', 'ConduitClientTestCase' => 'conduit/__tests__/ConduitClientTestCase.php', 'ConduitFuture' => 'conduit/ConduitFuture.php', 'ExecFuture' => 'future/exec/ExecFuture.php', 'ExecFutureTestCase' => 'future/exec/__tests__/ExecFutureTestCase.php', 'ExecPassthruTestCase' => 'future/exec/__tests__/ExecPassthruTestCase.php', 'FileFinder' => 'filesystem/FileFinder.php', 'FileFinderTestCase' => 'filesystem/__tests__/FileFinderTestCase.php', 'FileList' => 'filesystem/FileList.php', 'Filesystem' => 'filesystem/Filesystem.php', 'FilesystemException' => 'filesystem/FilesystemException.php', 'FilesystemTestCase' => 'filesystem/__tests__/FilesystemTestCase.php', 'Future' => 'future/Future.php', 'FutureIterator' => 'future/FutureIterator.php', 'FutureIteratorTestCase' => 'future/__tests__/FutureIteratorTestCase.php', 'FutureProxy' => 'future/FutureProxy.php', 'HTTPFuture' => 'future/http/HTTPFuture.php', 'HTTPFutureCURLResponseStatus' => 'future/http/status/HTTPFutureCURLResponseStatus.php', 'HTTPFutureCertificateResponseStatus' => 'future/http/status/HTTPFutureCertificateResponseStatus.php', 'HTTPFutureHTTPResponseStatus' => 'future/http/status/HTTPFutureHTTPResponseStatus.php', 'HTTPFutureParseResponseStatus' => 'future/http/status/HTTPFutureParseResponseStatus.php', 'HTTPFutureResponseStatus' => 'future/http/status/HTTPFutureResponseStatus.php', 'HTTPFutureTransportResponseStatus' => 'future/http/status/HTTPFutureTransportResponseStatus.php', 'HTTPSFuture' => 'future/http/HTTPSFuture.php', 'ImmediateFuture' => 'future/ImmediateFuture.php', 'LibphutilUSEnglishTranslation' => 'internationalization/translation/LibphutilUSEnglishTranslation.php', 'LinesOfALarge' => 'filesystem/linesofalarge/LinesOfALarge.php', 'LinesOfALargeExecFuture' => 'filesystem/linesofalarge/LinesOfALargeExecFuture.php', 'LinesOfALargeExecFutureTestCase' => 'filesystem/linesofalarge/__tests__/LinesOfALargeExecFutureTestCase.php', 'LinesOfALargeFile' => 'filesystem/linesofalarge/LinesOfALargeFile.php', 'LinesOfALargeFileTestCase' => 'filesystem/linesofalarge/__tests__/LinesOfALargeFileTestCase.php', 'MFilterTestHelper' => 'utils/__tests__/MFilterTestHelper.php', 'PHPASTParserTestCase' => 'parser/xhpast/__tests__/PHPASTParserTestCase.php', 'PhageAgentBootloader' => 'phage/bootloader/PhageAgentBootloader.php', 'PhageAgentTestCase' => 'phage/__tests__/PhageAgentTestCase.php', 'PhagePHPAgent' => 'phage/agent/PhagePHPAgent.php', 'PhagePHPAgentBootloader' => 'phage/bootloader/PhagePHPAgentBootloader.php', 'Phobject' => 'object/Phobject.php', 'PhobjectTestCase' => 'object/__tests__/PhobjectTestCase.php', 'PhutilAPCKeyValueCache' => 'cache/PhutilAPCKeyValueCache.php', 'PhutilAWSEC2Future' => 'future/aws/PhutilAWSEC2Future.php', 'PhutilAWSException' => 'future/aws/PhutilAWSException.php', 'PhutilAWSFuture' => 'future/aws/PhutilAWSFuture.php', 'PhutilAWSManagementWorkflow' => 'future/aws/management/PhutilAWSManagementWorkflow.php', 'PhutilAWSS3DeleteManagementWorkflow' => 'future/aws/management/PhutilAWSS3DeleteManagementWorkflow.php', 'PhutilAWSS3Future' => 'future/aws/PhutilAWSS3Future.php', 'PhutilAWSS3GetManagementWorkflow' => 'future/aws/management/PhutilAWSS3GetManagementWorkflow.php', 'PhutilAWSS3ManagementWorkflow' => 'future/aws/management/PhutilAWSS3ManagementWorkflow.php', 'PhutilAWSS3PutManagementWorkflow' => 'future/aws/management/PhutilAWSS3PutManagementWorkflow.php', 'PhutilAWSv4Signature' => 'future/aws/PhutilAWSv4Signature.php', 'PhutilAWSv4SignatureTestCase' => 'future/aws/__tests__/PhutilAWSv4SignatureTestCase.php', 'PhutilAggregateException' => 'error/PhutilAggregateException.php', 'PhutilAllCapsEnglishLocale' => 'internationalization/locales/PhutilAllCapsEnglishLocale.php', 'PhutilAmazonAuthAdapter' => 'auth/PhutilAmazonAuthAdapter.php', 'PhutilArgumentParser' => 'parser/argument/PhutilArgumentParser.php', 'PhutilArgumentParserException' => 'parser/argument/exception/PhutilArgumentParserException.php', 'PhutilArgumentParserTestCase' => 'parser/argument/__tests__/PhutilArgumentParserTestCase.php', 'PhutilArgumentSpecification' => 'parser/argument/PhutilArgumentSpecification.php', 'PhutilArgumentSpecificationException' => 'parser/argument/exception/PhutilArgumentSpecificationException.php', 'PhutilArgumentSpecificationTestCase' => 'parser/argument/__tests__/PhutilArgumentSpecificationTestCase.php', 'PhutilArgumentSpellingCorrector' => 'parser/argument/PhutilArgumentSpellingCorrector.php', 'PhutilArgumentSpellingCorrectorTestCase' => 'parser/argument/__tests__/PhutilArgumentSpellingCorrectorTestCase.php', 'PhutilArgumentUsageException' => 'parser/argument/exception/PhutilArgumentUsageException.php', 'PhutilArgumentWorkflow' => 'parser/argument/workflow/PhutilArgumentWorkflow.php', 'PhutilArray' => 'utils/PhutilArray.php', 'PhutilArrayTestCase' => 'utils/__tests__/PhutilArrayTestCase.php', 'PhutilArrayWithDefaultValue' => 'utils/PhutilArrayWithDefaultValue.php', 'PhutilAsanaAuthAdapter' => 'auth/PhutilAsanaAuthAdapter.php', 'PhutilAsanaFuture' => 'future/asana/PhutilAsanaFuture.php', 'PhutilAuthAdapter' => 'auth/PhutilAuthAdapter.php', 'PhutilAuthConfigurationException' => 'auth/exception/PhutilAuthConfigurationException.php', 'PhutilAuthCredentialException' => 'auth/exception/PhutilAuthCredentialException.php', 'PhutilAuthException' => 'auth/exception/PhutilAuthException.php', 'PhutilAuthUserAbortedException' => 'auth/exception/PhutilAuthUserAbortedException.php', 'PhutilBacktraceSignalHandler' => 'future/exec/PhutilBacktraceSignalHandler.php', 'PhutilBallOfPHP' => 'phage/util/PhutilBallOfPHP.php', 'PhutilBitbucketAuthAdapter' => 'auth/PhutilBitbucketAuthAdapter.php', 'PhutilBootloader' => 'moduleutils/PhutilBootloader.php', 'PhutilBootloaderException' => 'moduleutils/PhutilBootloaderException.php', 'PhutilBritishEnglishLocale' => 'internationalization/locales/PhutilBritishEnglishLocale.php', 'PhutilBufferedIterator' => 'utils/PhutilBufferedIterator.php', 'PhutilBufferedIteratorTestCase' => 'utils/__tests__/PhutilBufferedIteratorTestCase.php', 'PhutilBugtraqParser' => 'parser/PhutilBugtraqParser.php', 'PhutilBugtraqParserTestCase' => 'parser/__tests__/PhutilBugtraqParserTestCase.php', 'PhutilCIDRBlock' => 'ip/PhutilCIDRBlock.php', 'PhutilCIDRList' => 'ip/PhutilCIDRList.php', 'PhutilCLikeCodeSnippetContextFreeGrammar' => 'grammar/code/PhutilCLikeCodeSnippetContextFreeGrammar.php', 'PhutilCalendarAbsoluteDateTime' => 'parser/calendar/data/PhutilCalendarAbsoluteDateTime.php', 'PhutilCalendarContainerNode' => 'parser/calendar/data/PhutilCalendarContainerNode.php', 'PhutilCalendarDateTime' => 'parser/calendar/data/PhutilCalendarDateTime.php', 'PhutilCalendarDocumentNode' => 'parser/calendar/data/PhutilCalendarDocumentNode.php', 'PhutilCalendarDuration' => 'parser/calendar/data/PhutilCalendarDuration.php', 'PhutilCalendarEventNode' => 'parser/calendar/data/PhutilCalendarEventNode.php', 'PhutilCalendarNode' => 'parser/calendar/data/PhutilCalendarNode.php', 'PhutilCalendarProxyDateTime' => 'parser/calendar/data/PhutilCalendarProxyDateTime.php', 'PhutilCalendarRawNode' => 'parser/calendar/data/PhutilCalendarRawNode.php', 'PhutilCalendarRecurrenceList' => 'parser/calendar/data/PhutilCalendarRecurrenceList.php', 'PhutilCalendarRecurrenceRule' => 'parser/calendar/data/PhutilCalendarRecurrenceRule.php', + 'PhutilCalendarRecurrenceRuleTestCase' => 'parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php', 'PhutilCalendarRecurrenceSet' => 'parser/calendar/data/PhutilCalendarRecurrenceSet.php', 'PhutilCalendarRecurrenceSource' => 'parser/calendar/data/PhutilCalendarRecurrenceSource.php', 'PhutilCalendarRecurrenceTestCase' => 'parser/calendar/data/__tests__/PhutilCalendarRecurrenceTestCase.php', 'PhutilCalendarRelativeDateTime' => 'parser/calendar/data/PhutilCalendarRelativeDateTime.php', 'PhutilCalendarRootNode' => 'parser/calendar/data/PhutilCalendarRootNode.php', 'PhutilCalendarUserNode' => 'parser/calendar/data/PhutilCalendarUserNode.php', 'PhutilCallbackFilterIterator' => 'utils/PhutilCallbackFilterIterator.php', 'PhutilCallbackSignalHandler' => 'future/exec/PhutilCallbackSignalHandler.php', 'PhutilChannel' => 'channel/PhutilChannel.php', 'PhutilChannelChannel' => 'channel/PhutilChannelChannel.php', 'PhutilChannelTestCase' => 'channel/__tests__/PhutilChannelTestCase.php', 'PhutilChunkedIterator' => 'utils/PhutilChunkedIterator.php', 'PhutilChunkedIteratorTestCase' => 'utils/__tests__/PhutilChunkedIteratorTestCase.php', 'PhutilClassMapQuery' => 'symbols/PhutilClassMapQuery.php', 'PhutilCodeSnippetContextFreeGrammar' => 'grammar/code/PhutilCodeSnippetContextFreeGrammar.php', 'PhutilCommandString' => 'xsprintf/PhutilCommandString.php', 'PhutilConsole' => 'console/PhutilConsole.php', 'PhutilConsoleBlock' => 'console/view/PhutilConsoleBlock.php', 'PhutilConsoleConcatenatedView' => 'console/view/PhutilConsoleConcatenatedView.php', 'PhutilConsoleFormatter' => 'console/PhutilConsoleFormatter.php', 'PhutilConsoleList' => 'console/view/PhutilConsoleList.php', 'PhutilConsoleMessage' => 'console/PhutilConsoleMessage.php', 'PhutilConsoleProgressBar' => 'console/PhutilConsoleProgressBar.php', 'PhutilConsoleServer' => 'console/PhutilConsoleServer.php', 'PhutilConsoleServerChannel' => 'console/PhutilConsoleServerChannel.php', 'PhutilConsoleStdinNotInteractiveException' => 'console/PhutilConsoleStdinNotInteractiveException.php', 'PhutilConsoleSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilConsoleSyntaxHighlighter.php', 'PhutilConsoleTable' => 'console/view/PhutilConsoleTable.php', 'PhutilConsoleView' => 'console/view/PhutilConsoleView.php', 'PhutilConsoleWrapTestCase' => 'console/__tests__/PhutilConsoleWrapTestCase.php', 'PhutilContextFreeGrammar' => 'grammar/PhutilContextFreeGrammar.php', 'PhutilCowsay' => 'utils/PhutilCowsay.php', 'PhutilCowsayTestCase' => 'utils/__tests__/PhutilCowsayTestCase.php', 'PhutilCsprintfTestCase' => 'xsprintf/__tests__/PhutilCsprintfTestCase.php', 'PhutilCzechLocale' => 'internationalization/locales/PhutilCzechLocale.php', 'PhutilDaemon' => 'daemon/PhutilDaemon.php', 'PhutilDaemonHandle' => 'daemon/PhutilDaemonHandle.php', 'PhutilDaemonOverseer' => 'daemon/PhutilDaemonOverseer.php', 'PhutilDaemonOverseerModule' => 'daemon/PhutilDaemonOverseerModule.php', 'PhutilDefaultSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilDefaultSyntaxHighlighter.php', 'PhutilDefaultSyntaxHighlighterEngine' => 'markup/syntax/engine/PhutilDefaultSyntaxHighlighterEngine.php', 'PhutilDefaultSyntaxHighlighterEnginePygmentsFuture' => 'markup/syntax/highlighter/pygments/PhutilDefaultSyntaxHighlighterEnginePygmentsFuture.php', 'PhutilDefaultSyntaxHighlighterEngineTestCase' => 'markup/syntax/engine/__tests__/PhutilDefaultSyntaxHighlighterEngineTestCase.php', 'PhutilDeferredLog' => 'filesystem/PhutilDeferredLog.php', 'PhutilDeferredLogTestCase' => 'filesystem/__tests__/PhutilDeferredLogTestCase.php', 'PhutilDirectedScalarGraph' => 'utils/PhutilDirectedScalarGraph.php', 'PhutilDirectoryFixture' => 'filesystem/PhutilDirectoryFixture.php', 'PhutilDirectoryKeyValueCache' => 'cache/PhutilDirectoryKeyValueCache.php', 'PhutilDisqusAuthAdapter' => 'auth/PhutilDisqusAuthAdapter.php', 'PhutilDivinerSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilDivinerSyntaxHighlighter.php', 'PhutilDocblockParser' => 'parser/PhutilDocblockParser.php', 'PhutilDocblockParserTestCase' => 'parser/__tests__/PhutilDocblockParserTestCase.php', 'PhutilEditDistanceMatrix' => 'utils/PhutilEditDistanceMatrix.php', 'PhutilEditDistanceMatrixTestCase' => 'utils/__tests__/PhutilEditDistanceMatrixTestCase.php', 'PhutilEditorConfig' => 'parser/PhutilEditorConfig.php', 'PhutilEditorConfigTestCase' => 'parser/__tests__/PhutilEditorConfigTestCase.php', 'PhutilEmailAddress' => 'parser/PhutilEmailAddress.php', 'PhutilEmailAddressTestCase' => 'parser/__tests__/PhutilEmailAddressTestCase.php', 'PhutilEmojiLocale' => 'internationalization/locales/PhutilEmojiLocale.php', 'PhutilEmptyAuthAdapter' => 'auth/PhutilEmptyAuthAdapter.php', 'PhutilEnglishCanadaLocale' => 'internationalization/locales/PhutilEnglishCanadaLocale.php', 'PhutilErrorHandler' => 'error/PhutilErrorHandler.php', 'PhutilErrorHandlerTestCase' => 'error/__tests__/PhutilErrorHandlerTestCase.php', 'PhutilErrorTrap' => 'error/PhutilErrorTrap.php', 'PhutilEvent' => 'events/PhutilEvent.php', 'PhutilEventConstants' => 'events/constant/PhutilEventConstants.php', 'PhutilEventEngine' => 'events/PhutilEventEngine.php', 'PhutilEventListener' => 'events/PhutilEventListener.php', 'PhutilEventType' => 'events/constant/PhutilEventType.php', 'PhutilExampleBufferedIterator' => 'utils/PhutilExampleBufferedIterator.php', 'PhutilExcessiveServiceCallsDaemon' => 'daemon/torture/PhutilExcessiveServiceCallsDaemon.php', 'PhutilExecChannel' => 'channel/PhutilExecChannel.php', 'PhutilExecPassthru' => 'future/exec/PhutilExecPassthru.php', 'PhutilExecutableFuture' => 'future/exec/PhutilExecutableFuture.php', 'PhutilExecutionEnvironment' => 'utils/PhutilExecutionEnvironment.php', 'PhutilExtensionsTestCase' => 'moduleutils/__tests__/PhutilExtensionsTestCase.php', 'PhutilFacebookAuthAdapter' => 'auth/PhutilFacebookAuthAdapter.php', 'PhutilFatalDaemon' => 'daemon/torture/PhutilFatalDaemon.php', 'PhutilFileLock' => 'filesystem/PhutilFileLock.php', 'PhutilFileLockTestCase' => 'filesystem/__tests__/PhutilFileLockTestCase.php', 'PhutilFileTree' => 'filesystem/PhutilFileTree.php', 'PhutilFrenchLocale' => 'internationalization/locales/PhutilFrenchLocale.php', 'PhutilGermanLocale' => 'internationalization/locales/PhutilGermanLocale.php', 'PhutilGitHubAuthAdapter' => 'auth/PhutilGitHubAuthAdapter.php', 'PhutilGitHubFuture' => 'future/github/PhutilGitHubFuture.php', 'PhutilGitHubResponse' => 'future/github/PhutilGitHubResponse.php', 'PhutilGitURI' => 'parser/PhutilGitURI.php', 'PhutilGitURITestCase' => 'parser/__tests__/PhutilGitURITestCase.php', 'PhutilGoogleAuthAdapter' => 'auth/PhutilGoogleAuthAdapter.php', 'PhutilHTTPEngineExtension' => 'future/http/PhutilHTTPEngineExtension.php', 'PhutilHangForeverDaemon' => 'daemon/torture/PhutilHangForeverDaemon.php', 'PhutilHashingIterator' => 'utils/PhutilHashingIterator.php', 'PhutilHashingIteratorTestCase' => 'utils/__tests__/PhutilHashingIteratorTestCase.php', 'PhutilHelpArgumentWorkflow' => 'parser/argument/workflow/PhutilHelpArgumentWorkflow.php', 'PhutilHgsprintfTestCase' => 'xsprintf/__tests__/PhutilHgsprintfTestCase.php', 'PhutilHighIntensityIntervalDaemon' => 'daemon/torture/PhutilHighIntensityIntervalDaemon.php', 'PhutilICSParser' => 'parser/calendar/ics/PhutilICSParser.php', 'PhutilICSParserException' => 'parser/calendar/ics/PhutilICSParserException.php', 'PhutilICSParserTestCase' => 'parser/calendar/ics/__tests__/PhutilICSParserTestCase.php', 'PhutilICSWriter' => 'parser/calendar/ics/PhutilICSWriter.php', 'PhutilICSWriterTestCase' => 'parser/calendar/ics/__tests__/PhutilICSWriterTestCase.php', 'PhutilINIParserException' => 'parser/exception/PhutilINIParserException.php', 'PhutilIPAddress' => 'ip/PhutilIPAddress.php', 'PhutilIPAddressTestCase' => 'ip/__tests__/PhutilIPAddressTestCase.php', 'PhutilInRequestKeyValueCache' => 'cache/PhutilInRequestKeyValueCache.php', 'PhutilInteractiveEditor' => 'console/PhutilInteractiveEditor.php', 'PhutilInvalidRuleParserGeneratorException' => 'parser/generator/exception/PhutilInvalidRuleParserGeneratorException.php', 'PhutilInvalidStateException' => 'exception/PhutilInvalidStateException.php', 'PhutilInvalidStateExceptionTestCase' => 'exception/__tests__/PhutilInvalidStateExceptionTestCase.php', 'PhutilInvisibleSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilInvisibleSyntaxHighlighter.php', 'PhutilIrreducibleRuleParserGeneratorException' => 'parser/generator/exception/PhutilIrreducibleRuleParserGeneratorException.php', 'PhutilJIRAAuthAdapter' => 'auth/PhutilJIRAAuthAdapter.php', 'PhutilJSON' => 'parser/PhutilJSON.php', 'PhutilJSONFragmentLexer' => 'lexer/PhutilJSONFragmentLexer.php', 'PhutilJSONFragmentLexerHighlighterTestCase' => 'markup/syntax/highlighter/__tests__/PhutilJSONFragmentLexerHighlighterTestCase.php', 'PhutilJSONParser' => 'parser/PhutilJSONParser.php', 'PhutilJSONParserException' => 'parser/exception/PhutilJSONParserException.php', 'PhutilJSONParserTestCase' => 'parser/__tests__/PhutilJSONParserTestCase.php', 'PhutilJSONProtocolChannel' => 'channel/PhutilJSONProtocolChannel.php', 'PhutilJSONProtocolChannelTestCase' => 'channel/__tests__/PhutilJSONProtocolChannelTestCase.php', 'PhutilJSONTestCase' => 'parser/__tests__/PhutilJSONTestCase.php', 'PhutilJavaCodeSnippetContextFreeGrammar' => 'grammar/code/PhutilJavaCodeSnippetContextFreeGrammar.php', 'PhutilKeyValueCache' => 'cache/PhutilKeyValueCache.php', 'PhutilKeyValueCacheNamespace' => 'cache/PhutilKeyValueCacheNamespace.php', 'PhutilKeyValueCacheProfiler' => 'cache/PhutilKeyValueCacheProfiler.php', 'PhutilKeyValueCacheProxy' => 'cache/PhutilKeyValueCacheProxy.php', 'PhutilKeyValueCacheStack' => 'cache/PhutilKeyValueCacheStack.php', 'PhutilKeyValueCacheTestCase' => 'cache/__tests__/PhutilKeyValueCacheTestCase.php', 'PhutilKoreanLocale' => 'internationalization/locales/PhutilKoreanLocale.php', 'PhutilLDAPAuthAdapter' => 'auth/PhutilLDAPAuthAdapter.php', 'PhutilLanguageGuesser' => 'parser/PhutilLanguageGuesser.php', 'PhutilLanguageGuesserTestCase' => 'parser/__tests__/PhutilLanguageGuesserTestCase.php', 'PhutilLexer' => 'lexer/PhutilLexer.php', 'PhutilLexerSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilLexerSyntaxHighlighter.php', 'PhutilLibraryConflictException' => 'moduleutils/PhutilLibraryConflictException.php', 'PhutilLibraryMapBuilder' => 'moduleutils/PhutilLibraryMapBuilder.php', 'PhutilLibraryTestCase' => '__tests__/PhutilLibraryTestCase.php', 'PhutilLipsumContextFreeGrammar' => 'grammar/PhutilLipsumContextFreeGrammar.php', 'PhutilLocale' => 'internationalization/PhutilLocale.php', 'PhutilLocaleTestCase' => 'internationalization/__tests__/PhutilLocaleTestCase.php', 'PhutilLock' => 'filesystem/PhutilLock.php', 'PhutilLockException' => 'filesystem/PhutilLockException.php', 'PhutilLogFileChannel' => 'channel/PhutilLogFileChannel.php', 'PhutilLunarPhase' => 'utils/PhutilLunarPhase.php', 'PhutilLunarPhaseTestCase' => 'utils/__tests__/PhutilLunarPhaseTestCase.php', 'PhutilMarkupEngine' => 'markup/PhutilMarkupEngine.php', 'PhutilMarkupTestCase' => 'markup/__tests__/PhutilMarkupTestCase.php', 'PhutilMemcacheKeyValueCache' => 'cache/PhutilMemcacheKeyValueCache.php', 'PhutilMethodNotImplementedException' => 'error/PhutilMethodNotImplementedException.php', 'PhutilMetricsChannel' => 'channel/PhutilMetricsChannel.php', 'PhutilMissingSymbolException' => 'symbols/exception/PhutilMissingSymbolException.php', 'PhutilModuleUtilsTestCase' => 'moduleutils/__tests__/PhutilModuleUtilsTestCase.php', 'PhutilNiceDaemon' => 'daemon/torture/PhutilNiceDaemon.php', 'PhutilNumber' => 'internationalization/PhutilNumber.php', 'PhutilOAuth1AuthAdapter' => 'auth/PhutilOAuth1AuthAdapter.php', 'PhutilOAuth1Future' => 'future/oauth/PhutilOAuth1Future.php', 'PhutilOAuth1FutureTestCase' => 'future/oauth/__tests__/PhutilOAuth1FutureTestCase.php', 'PhutilOAuthAuthAdapter' => 'auth/PhutilOAuthAuthAdapter.php', 'PhutilOnDiskKeyValueCache' => 'cache/PhutilOnDiskKeyValueCache.php', 'PhutilOpaqueEnvelope' => 'error/PhutilOpaqueEnvelope.php', 'PhutilOpaqueEnvelopeKey' => 'error/PhutilOpaqueEnvelopeKey.php', 'PhutilOpaqueEnvelopeTestCase' => 'error/__tests__/PhutilOpaqueEnvelopeTestCase.php', 'PhutilPHPCodeSnippetContextFreeGrammar' => 'grammar/code/PhutilPHPCodeSnippetContextFreeGrammar.php', 'PhutilPHPFragmentLexer' => 'lexer/PhutilPHPFragmentLexer.php', 'PhutilPHPFragmentLexerHighlighterTestCase' => 'markup/syntax/highlighter/__tests__/PhutilPHPFragmentLexerHighlighterTestCase.php', 'PhutilPHPFragmentLexerTestCase' => 'lexer/__tests__/PhutilPHPFragmentLexerTestCase.php', 'PhutilPHPObjectProtocolChannel' => 'channel/PhutilPHPObjectProtocolChannel.php', 'PhutilPHPObjectProtocolChannelTestCase' => 'channel/__tests__/PhutilPHPObjectProtocolChannelTestCase.php', 'PhutilParserGenerator' => 'parser/PhutilParserGenerator.php', 'PhutilParserGeneratorException' => 'parser/generator/exception/PhutilParserGeneratorException.php', 'PhutilParserGeneratorTestCase' => 'parser/__tests__/PhutilParserGeneratorTestCase.php', 'PhutilPayPalAPIFuture' => 'future/paypal/PhutilPayPalAPIFuture.php', 'PhutilPerson' => 'internationalization/PhutilPerson.php', 'PhutilPersonTest' => 'internationalization/__tests__/PhutilPersonTest.php', 'PhutilPersonaAuthAdapter' => 'auth/PhutilPersonaAuthAdapter.php', 'PhutilPhabricatorAuthAdapter' => 'auth/PhutilPhabricatorAuthAdapter.php', 'PhutilPhtTestCase' => 'internationalization/__tests__/PhutilPhtTestCase.php', 'PhutilPirateEnglishLocale' => 'internationalization/locales/PhutilPirateEnglishLocale.php', 'PhutilPortugueseBrazilLocale' => 'internationalization/locales/PhutilPortugueseBrazilLocale.php', 'PhutilPortuguesePortugalLocale' => 'internationalization/locales/PhutilPortuguesePortugalLocale.php', 'PhutilPregsprintfTestCase' => 'xsprintf/__tests__/PhutilPregsprintfTestCase.php', 'PhutilProcessGroupDaemon' => 'daemon/torture/PhutilProcessGroupDaemon.php', 'PhutilProseDiff' => 'utils/PhutilProseDiff.php', 'PhutilProseDiffTestCase' => 'utils/__tests__/PhutilProseDiffTestCase.php', 'PhutilProseDifferenceEngine' => 'utils/PhutilProseDifferenceEngine.php', 'PhutilProtocolChannel' => 'channel/PhutilProtocolChannel.php', 'PhutilProxyException' => 'error/PhutilProxyException.php', 'PhutilProxyIterator' => 'utils/PhutilProxyIterator.php', 'PhutilPygmentizeParser' => 'parser/PhutilPygmentizeParser.php', 'PhutilPygmentizeParserTestCase' => 'parser/__tests__/PhutilPygmentizeParserTestCase.php', 'PhutilPygmentsSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilPygmentsSyntaxHighlighter.php', 'PhutilPythonFragmentLexer' => 'lexer/PhutilPythonFragmentLexer.php', 'PhutilQsprintfInterface' => 'xsprintf/PhutilQsprintfInterface.php', 'PhutilQueryStringParser' => 'parser/PhutilQueryStringParser.php', 'PhutilQueryStringParserTestCase' => 'parser/__tests__/PhutilQueryStringParserTestCase.php', 'PhutilRainbowSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilRainbowSyntaxHighlighter.php', 'PhutilRawEnglishLocale' => 'internationalization/locales/PhutilRawEnglishLocale.php', 'PhutilReadableSerializer' => 'readableserializer/PhutilReadableSerializer.php', 'PhutilReadableSerializerTestCase' => 'readableserializer/__tests__/PhutilReadableSerializerTestCase.php', 'PhutilRealNameContextFreeGrammar' => 'grammar/PhutilRealNameContextFreeGrammar.php', 'PhutilRemarkupBlockInterpreter' => 'markup/engine/remarkup/blockrule/PhutilRemarkupBlockInterpreter.php', 'PhutilRemarkupBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupBlockRule.php', 'PhutilRemarkupBlockStorage' => 'markup/engine/remarkup/PhutilRemarkupBlockStorage.php', 'PhutilRemarkupBoldRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupBoldRule.php', 'PhutilRemarkupCodeBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupCodeBlockRule.php', 'PhutilRemarkupDefaultBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupDefaultBlockRule.php', 'PhutilRemarkupDelRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupDelRule.php', 'PhutilRemarkupDocumentLinkRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupDocumentLinkRule.php', 'PhutilRemarkupEngine' => 'markup/engine/PhutilRemarkupEngine.php', 'PhutilRemarkupEngineTestCase' => 'markup/engine/__tests__/PhutilRemarkupEngineTestCase.php', 'PhutilRemarkupEscapeRemarkupRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupEscapeRemarkupRule.php', 'PhutilRemarkupHeaderBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupHeaderBlockRule.php', 'PhutilRemarkupHighlightRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupHighlightRule.php', 'PhutilRemarkupHorizontalRuleBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupHorizontalRuleBlockRule.php', 'PhutilRemarkupHyperlinkRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkRule.php', 'PhutilRemarkupInlineBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupInlineBlockRule.php', 'PhutilRemarkupInterpreterBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupInterpreterBlockRule.php', 'PhutilRemarkupItalicRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupItalicRule.php', 'PhutilRemarkupLinebreaksRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupLinebreaksRule.php', 'PhutilRemarkupListBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupListBlockRule.php', 'PhutilRemarkupLiteralBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupLiteralBlockRule.php', 'PhutilRemarkupMonospaceRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupMonospaceRule.php', 'PhutilRemarkupNoteBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupNoteBlockRule.php', 'PhutilRemarkupQuotesBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupQuotesBlockRule.php', 'PhutilRemarkupReplyBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupReplyBlockRule.php', 'PhutilRemarkupRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupRule.php', 'PhutilRemarkupSimpleTableBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupSimpleTableBlockRule.php', 'PhutilRemarkupTableBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupTableBlockRule.php', 'PhutilRemarkupTestInterpreterRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupTestInterpreterRule.php', 'PhutilRemarkupUnderlineRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupUnderlineRule.php', 'PhutilRope' => 'utils/PhutilRope.php', 'PhutilRopeTestCase' => 'utils/__tests__/PhutilRopeTestCase.php', 'PhutilSafeHTML' => 'markup/PhutilSafeHTML.php', 'PhutilSafeHTMLProducerInterface' => 'markup/PhutilSafeHTMLProducerInterface.php', 'PhutilSafeHTMLTestCase' => 'markup/__tests__/PhutilSafeHTMLTestCase.php', 'PhutilSaturateStdoutDaemon' => 'daemon/torture/PhutilSaturateStdoutDaemon.php', 'PhutilServiceProfiler' => 'serviceprofiler/PhutilServiceProfiler.php', 'PhutilShellLexer' => 'lexer/PhutilShellLexer.php', 'PhutilShellLexerTestCase' => 'lexer/__tests__/PhutilShellLexerTestCase.php', 'PhutilSignalHandler' => 'future/exec/PhutilSignalHandler.php', 'PhutilSignalRouter' => 'future/exec/PhutilSignalRouter.php', 'PhutilSimpleOptions' => 'parser/PhutilSimpleOptions.php', 'PhutilSimpleOptionsLexer' => 'lexer/PhutilSimpleOptionsLexer.php', 'PhutilSimpleOptionsLexerTestCase' => 'lexer/__tests__/PhutilSimpleOptionsLexerTestCase.php', 'PhutilSimpleOptionsTestCase' => 'parser/__tests__/PhutilSimpleOptionsTestCase.php', 'PhutilSimplifiedChineseLocale' => 'internationalization/locales/PhutilSimplifiedChineseLocale.php', 'PhutilSlackAuthAdapter' => 'auth/PhutilSlackAuthAdapter.php', 'PhutilSlackFuture' => 'future/slack/PhutilSlackFuture.php', 'PhutilSocketChannel' => 'channel/PhutilSocketChannel.php', 'PhutilSortVector' => 'utils/PhutilSortVector.php', 'PhutilSpanishSpainLocale' => 'internationalization/locales/PhutilSpanishSpainLocale.php', 'PhutilSprite' => 'sprites/PhutilSprite.php', 'PhutilSpriteSheet' => 'sprites/PhutilSpriteSheet.php', 'PhutilStreamIterator' => 'utils/PhutilStreamIterator.php', 'PhutilSymbolLoader' => 'symbols/PhutilSymbolLoader.php', 'PhutilSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilSyntaxHighlighter.php', 'PhutilSyntaxHighlighterEngine' => 'markup/syntax/engine/PhutilSyntaxHighlighterEngine.php', 'PhutilSyntaxHighlighterException' => 'markup/syntax/highlighter/PhutilSyntaxHighlighterException.php', 'PhutilSystem' => 'utils/PhutilSystem.php', 'PhutilSystemTestCase' => 'utils/__tests__/PhutilSystemTestCase.php', 'PhutilTerminalString' => 'xsprintf/PhutilTerminalString.php', 'PhutilTestPhobject' => 'object/__tests__/PhutilTestPhobject.php', 'PhutilTortureTestDaemon' => 'daemon/torture/PhutilTortureTestDaemon.php', 'PhutilTraditionalChineseLocale' => 'internationalization/locales/PhutilTraditionalChineseLocale.php', 'PhutilTranslation' => 'internationalization/PhutilTranslation.php', 'PhutilTranslationTestCase' => 'internationalization/__tests__/PhutilTranslationTestCase.php', 'PhutilTranslator' => 'internationalization/PhutilTranslator.php', 'PhutilTranslatorTestCase' => 'internationalization/__tests__/PhutilTranslatorTestCase.php', 'PhutilTsprintfTestCase' => 'xsprintf/__tests__/PhutilTsprintfTestCase.php', 'PhutilTwitchAuthAdapter' => 'auth/PhutilTwitchAuthAdapter.php', 'PhutilTwitchFuture' => 'future/twitch/PhutilTwitchFuture.php', 'PhutilTwitterAuthAdapter' => 'auth/PhutilTwitterAuthAdapter.php', 'PhutilTypeCheckException' => 'parser/exception/PhutilTypeCheckException.php', 'PhutilTypeExtraParametersException' => 'parser/exception/PhutilTypeExtraParametersException.php', 'PhutilTypeLexer' => 'lexer/PhutilTypeLexer.php', 'PhutilTypeMissingParametersException' => 'parser/exception/PhutilTypeMissingParametersException.php', 'PhutilTypeSpec' => 'parser/PhutilTypeSpec.php', 'PhutilTypeSpecTestCase' => 'parser/__tests__/PhutilTypeSpecTestCase.php', 'PhutilURI' => 'parser/PhutilURI.php', 'PhutilURITestCase' => 'parser/__tests__/PhutilURITestCase.php', 'PhutilUSEnglishLocale' => 'internationalization/locales/PhutilUSEnglishLocale.php', 'PhutilUTF8StringTruncator' => 'utils/PhutilUTF8StringTruncator.php', 'PhutilUTF8TestCase' => 'utils/__tests__/PhutilUTF8TestCase.php', 'PhutilUnknownSymbolParserGeneratorException' => 'parser/generator/exception/PhutilUnknownSymbolParserGeneratorException.php', 'PhutilUnreachableRuleParserGeneratorException' => 'parser/generator/exception/PhutilUnreachableRuleParserGeneratorException.php', 'PhutilUnreachableTerminalParserGeneratorException' => 'parser/generator/exception/PhutilUnreachableTerminalParserGeneratorException.php', 'PhutilUrisprintfTestCase' => 'xsprintf/__tests__/PhutilUrisprintfTestCase.php', 'PhutilUtilsTestCase' => 'utils/__tests__/PhutilUtilsTestCase.php', 'PhutilVeryWowEnglishLocale' => 'internationalization/locales/PhutilVeryWowEnglishLocale.php', 'PhutilWordPressAuthAdapter' => 'auth/PhutilWordPressAuthAdapter.php', 'PhutilWordPressFuture' => 'future/wordpress/PhutilWordPressFuture.php', 'PhutilXHPASTBinary' => 'parser/xhpast/bin/PhutilXHPASTBinary.php', 'PhutilXHPASTSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilXHPASTSyntaxHighlighter.php', 'PhutilXHPASTSyntaxHighlighterFuture' => 'markup/syntax/highlighter/xhpast/PhutilXHPASTSyntaxHighlighterFuture.php', 'PhutilXHPASTSyntaxHighlighterTestCase' => 'markup/syntax/highlighter/__tests__/PhutilXHPASTSyntaxHighlighterTestCase.php', 'QueryFuture' => 'future/query/QueryFuture.php', 'TempFile' => 'filesystem/TempFile.php', 'TestAbstractDirectedGraph' => 'utils/__tests__/TestAbstractDirectedGraph.php', 'XHPASTNode' => 'parser/xhpast/api/XHPASTNode.php', 'XHPASTNodeTestCase' => 'parser/xhpast/api/__tests__/XHPASTNodeTestCase.php', 'XHPASTSyntaxErrorException' => 'parser/xhpast/api/XHPASTSyntaxErrorException.php', 'XHPASTToken' => 'parser/xhpast/api/XHPASTToken.php', 'XHPASTTree' => 'parser/xhpast/api/XHPASTTree.php', 'XHPASTTreeTestCase' => 'parser/xhpast/api/__tests__/XHPASTTreeTestCase.php', 'XsprintfUnknownConversionException' => 'xsprintf/exception/XsprintfUnknownConversionException.php', ), 'function' => array( 'array_fuse' => 'utils/utils.php', 'array_interleave' => 'utils/utils.php', 'array_mergev' => 'utils/utils.php', 'array_select_keys' => 'utils/utils.php', 'assert_instances_of' => 'utils/utils.php', 'assert_stringlike' => 'utils/utils.php', 'coalesce' => 'utils/utils.php', 'csprintf' => 'xsprintf/csprintf.php', 'exec_manual' => 'future/exec/execx.php', 'execx' => 'future/exec/execx.php', 'head' => 'utils/utils.php', 'head_key' => 'utils/utils.php', 'hgsprintf' => 'xsprintf/hgsprintf.php', 'hsprintf' => 'markup/render.php', 'id' => 'utils/utils.php', 'idx' => 'utils/utils.php', 'idxv' => 'utils/utils.php', 'ifilter' => 'utils/utils.php', 'igroup' => 'utils/utils.php', 'ipull' => 'utils/utils.php', 'isort' => 'utils/utils.php', 'jsprintf' => 'xsprintf/jsprintf.php', 'last' => 'utils/utils.php', 'last_key' => 'utils/utils.php', 'ldap_sprintf' => 'xsprintf/ldapsprintf.php', 'mfilter' => 'utils/utils.php', 'mgroup' => 'utils/utils.php', 'mpull' => 'utils/utils.php', 'msort' => 'utils/utils.php', 'msortv' => 'utils/utils.php', 'newv' => 'utils/utils.php', 'nonempty' => 'utils/utils.php', 'phlog' => 'error/phlog.php', 'pht' => 'internationalization/pht.php', 'phutil_censor_credentials' => 'utils/utils.php', 'phutil_console_confirm' => 'console/format.php', 'phutil_console_format' => 'console/format.php', 'phutil_console_get_terminal_width' => 'console/format.php', 'phutil_console_prompt' => 'console/format.php', 'phutil_console_require_tty' => 'console/format.php', 'phutil_console_wrap' => 'console/format.php', 'phutil_count' => 'internationalization/pht.php', 'phutil_date_format' => 'utils/viewutils.php', 'phutil_deprecated' => 'moduleutils/moduleutils.php', 'phutil_error_listener_example' => 'error/phlog.php', 'phutil_escape_html' => 'markup/render.php', 'phutil_escape_html_newlines' => 'markup/render.php', 'phutil_escape_uri' => 'markup/render.php', 'phutil_escape_uri_path_component' => 'markup/render.php', 'phutil_fnmatch' => 'utils/utils.php', 'phutil_format_bytes' => 'utils/viewutils.php', 'phutil_format_relative_time' => 'utils/viewutils.php', 'phutil_format_relative_time_detailed' => 'utils/viewutils.php', 'phutil_format_units_generic' => 'utils/viewutils.php', 'phutil_fwrite_nonblocking_stream' => 'utils/utils.php', 'phutil_get_current_library_name' => 'moduleutils/moduleutils.php', 'phutil_get_library_name_for_root' => 'moduleutils/moduleutils.php', 'phutil_get_library_root' => 'moduleutils/moduleutils.php', 'phutil_get_library_root_for_path' => 'moduleutils/moduleutils.php', 'phutil_get_signal_name' => 'future/exec/execx.php', 'phutil_hashes_are_identical' => 'utils/utils.php', 'phutil_implode_html' => 'markup/render.php', 'phutil_ini_decode' => 'utils/utils.php', 'phutil_is_hiphop_runtime' => 'utils/utils.php', 'phutil_is_utf8' => 'utils/utf8.php', 'phutil_is_utf8_slowly' => 'utils/utf8.php', 'phutil_is_utf8_with_only_bmp_characters' => 'utils/utf8.php', 'phutil_is_windows' => 'utils/utils.php', 'phutil_json_decode' => 'utils/utils.php', 'phutil_json_encode' => 'utils/utils.php', 'phutil_load_library' => 'moduleutils/core.php', 'phutil_loggable_string' => 'utils/utils.php', 'phutil_parse_bytes' => 'utils/viewutils.php', 'phutil_passthru' => 'future/exec/execx.php', 'phutil_register_library' => 'moduleutils/core.php', 'phutil_register_library_map' => 'moduleutils/core.php', 'phutil_safe_html' => 'markup/render.php', 'phutil_split_lines' => 'utils/utils.php', 'phutil_tag' => 'markup/render.php', 'phutil_tag_div' => 'markup/render.php', 'phutil_unescape_uri_path_component' => 'markup/render.php', 'phutil_units' => 'utils/utils.php', 'phutil_utf8_console_strlen' => 'utils/utf8.php', 'phutil_utf8_convert' => 'utils/utf8.php', 'phutil_utf8_encode_codepoint' => 'utils/utf8.php', 'phutil_utf8_hard_wrap' => 'utils/utf8.php', 'phutil_utf8_hard_wrap_html' => 'utils/utf8.php', 'phutil_utf8_is_combining_character' => 'utils/utf8.php', 'phutil_utf8_strlen' => 'utils/utf8.php', 'phutil_utf8_strtolower' => 'utils/utf8.php', 'phutil_utf8_strtoupper' => 'utils/utf8.php', 'phutil_utf8_strtr' => 'utils/utf8.php', 'phutil_utf8_ucwords' => 'utils/utf8.php', 'phutil_utf8ize' => 'utils/utf8.php', 'phutil_utf8v' => 'utils/utf8.php', 'phutil_utf8v_codepoints' => 'utils/utf8.php', 'phutil_utf8v_combine_characters' => 'utils/utf8.php', 'phutil_utf8v_combined' => 'utils/utf8.php', 'phutil_validate_json' => 'utils/utils.php', 'phutil_var_export' => 'utils/utils.php', 'ppull' => 'utils/utils.php', 'pregsprintf' => 'xsprintf/pregsprintf.php', 'qsprintf' => 'xsprintf/qsprintf.php', 'qsprintf_check_scalar_type' => 'xsprintf/qsprintf.php', 'qsprintf_check_type' => 'xsprintf/qsprintf.php', 'queryfx' => 'xsprintf/queryfx.php', 'queryfx_all' => 'xsprintf/queryfx.php', 'queryfx_one' => 'xsprintf/queryfx.php', 'tsprintf' => 'xsprintf/tsprintf.php', 'urisprintf' => 'xsprintf/urisprintf.php', 'vcsprintf' => 'xsprintf/csprintf.php', 'vjsprintf' => 'xsprintf/jsprintf.php', 'vqsprintf' => 'xsprintf/qsprintf.php', 'vurisprintf' => 'xsprintf/urisprintf.php', 'xhp_parser_node_constants' => 'parser/xhpast/parser_nodes.php', 'xhpast_parser_token_constants' => 'parser/xhpast/parser_tokens.php', 'xsprintf' => 'xsprintf/xsprintf.php', 'xsprintf_callback_example' => 'xsprintf/xsprintf.php', 'xsprintf_command' => 'xsprintf/csprintf.php', 'xsprintf_javascript' => 'xsprintf/jsprintf.php', 'xsprintf_ldap' => 'xsprintf/ldapsprintf.php', 'xsprintf_mercurial' => 'xsprintf/hgsprintf.php', 'xsprintf_query' => 'xsprintf/qsprintf.php', 'xsprintf_regex' => 'xsprintf/pregsprintf.php', 'xsprintf_terminal' => 'xsprintf/tsprintf.php', 'xsprintf_uri' => 'xsprintf/urisprintf.php', ), 'xmap' => array( 'AASTNode' => 'Phobject', 'AASTNodeList' => array( 'Phobject', 'Countable', 'Iterator', ), 'AASTToken' => 'Phobject', 'AASTTree' => 'Phobject', 'AbstractDirectedGraph' => 'Phobject', 'AbstractDirectedGraphTestCase' => 'PhutilTestCase', 'AphrontAccessDeniedQueryException' => 'AphrontQueryException', 'AphrontBaseMySQLDatabaseConnection' => 'AphrontDatabaseConnection', 'AphrontCharacterSetQueryException' => 'AphrontQueryException', 'AphrontConnectionLostQueryException' => 'AphrontRecoverableQueryException', 'AphrontConnectionQueryException' => 'AphrontQueryException', 'AphrontCountQueryException' => 'AphrontQueryException', 'AphrontDatabaseConnection' => array( 'Phobject', 'PhutilQsprintfInterface', ), 'AphrontDatabaseTransactionState' => 'Phobject', 'AphrontDeadlockQueryException' => 'AphrontRecoverableQueryException', 'AphrontDuplicateKeyQueryException' => 'AphrontQueryException', 'AphrontInvalidCredentialsQueryException' => 'AphrontQueryException', 'AphrontIsolatedDatabaseConnection' => 'AphrontDatabaseConnection', 'AphrontLockTimeoutQueryException' => 'AphrontRecoverableQueryException', 'AphrontMySQLDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection', 'AphrontMySQLiDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection', 'AphrontNotSupportedQueryException' => 'AphrontQueryException', 'AphrontObjectMissingQueryException' => 'AphrontQueryException', 'AphrontParameterQueryException' => 'AphrontQueryException', 'AphrontQueryException' => 'Exception', 'AphrontQueryTimeoutQueryException' => 'AphrontRecoverableQueryException', 'AphrontRecoverableQueryException' => 'AphrontQueryException', 'AphrontRequestStream' => 'Phobject', 'AphrontSchemaQueryException' => 'AphrontQueryException', 'AphrontScopedUnguardedWriteCapability' => 'Phobject', 'AphrontWriteGuard' => 'Phobject', 'BaseHTTPFuture' => 'Future', 'CaseInsensitiveArray' => 'PhutilArray', 'CaseInsensitiveArrayTestCase' => 'PhutilTestCase', 'CommandException' => 'Exception', 'ConduitClient' => 'Phobject', 'ConduitClientException' => 'Exception', 'ConduitClientTestCase' => 'PhutilTestCase', 'ConduitFuture' => 'FutureProxy', 'ExecFuture' => 'PhutilExecutableFuture', 'ExecFutureTestCase' => 'PhutilTestCase', 'ExecPassthruTestCase' => 'PhutilTestCase', 'FileFinder' => 'Phobject', 'FileFinderTestCase' => 'PhutilTestCase', 'FileList' => 'Phobject', 'Filesystem' => 'Phobject', 'FilesystemException' => 'Exception', 'FilesystemTestCase' => 'PhutilTestCase', 'Future' => 'Phobject', 'FutureIterator' => array( 'Phobject', 'Iterator', ), 'FutureIteratorTestCase' => 'PhutilTestCase', 'FutureProxy' => 'Future', 'HTTPFuture' => 'BaseHTTPFuture', 'HTTPFutureCURLResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPFutureCertificateResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPFutureHTTPResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPFutureParseResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPFutureResponseStatus' => 'Exception', 'HTTPFutureTransportResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPSFuture' => 'BaseHTTPFuture', 'ImmediateFuture' => 'Future', 'LibphutilUSEnglishTranslation' => 'PhutilTranslation', 'LinesOfALarge' => array( 'Phobject', 'Iterator', ), 'LinesOfALargeExecFuture' => 'LinesOfALarge', 'LinesOfALargeExecFutureTestCase' => 'PhutilTestCase', 'LinesOfALargeFile' => 'LinesOfALarge', 'LinesOfALargeFileTestCase' => 'PhutilTestCase', 'MFilterTestHelper' => 'Phobject', 'PHPASTParserTestCase' => 'PhutilTestCase', 'PhageAgentBootloader' => 'Phobject', 'PhageAgentTestCase' => 'PhutilTestCase', 'PhagePHPAgent' => 'Phobject', 'PhagePHPAgentBootloader' => 'PhageAgentBootloader', 'Phobject' => 'Iterator', 'PhobjectTestCase' => 'PhutilTestCase', 'PhutilAPCKeyValueCache' => 'PhutilKeyValueCache', 'PhutilAWSEC2Future' => 'PhutilAWSFuture', 'PhutilAWSException' => 'Exception', 'PhutilAWSFuture' => 'FutureProxy', 'PhutilAWSManagementWorkflow' => 'PhutilArgumentWorkflow', 'PhutilAWSS3DeleteManagementWorkflow' => 'PhutilAWSS3ManagementWorkflow', 'PhutilAWSS3Future' => 'PhutilAWSFuture', 'PhutilAWSS3GetManagementWorkflow' => 'PhutilAWSS3ManagementWorkflow', 'PhutilAWSS3ManagementWorkflow' => 'PhutilAWSManagementWorkflow', 'PhutilAWSS3PutManagementWorkflow' => 'PhutilAWSS3ManagementWorkflow', 'PhutilAWSv4Signature' => 'Phobject', 'PhutilAWSv4SignatureTestCase' => 'PhutilTestCase', 'PhutilAggregateException' => 'Exception', 'PhutilAllCapsEnglishLocale' => 'PhutilLocale', 'PhutilAmazonAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilArgumentParser' => 'Phobject', 'PhutilArgumentParserException' => 'Exception', 'PhutilArgumentParserTestCase' => 'PhutilTestCase', 'PhutilArgumentSpecification' => 'Phobject', 'PhutilArgumentSpecificationException' => 'PhutilArgumentParserException', 'PhutilArgumentSpecificationTestCase' => 'PhutilTestCase', 'PhutilArgumentSpellingCorrector' => 'Phobject', 'PhutilArgumentSpellingCorrectorTestCase' => 'PhutilTestCase', 'PhutilArgumentUsageException' => 'PhutilArgumentParserException', 'PhutilArgumentWorkflow' => 'Phobject', 'PhutilArray' => array( 'Phobject', 'Countable', 'ArrayAccess', 'Iterator', ), 'PhutilArrayTestCase' => 'PhutilTestCase', 'PhutilArrayWithDefaultValue' => 'PhutilArray', 'PhutilAsanaAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilAsanaFuture' => 'FutureProxy', 'PhutilAuthAdapter' => 'Phobject', 'PhutilAuthConfigurationException' => 'PhutilAuthException', 'PhutilAuthCredentialException' => 'PhutilAuthException', 'PhutilAuthException' => 'Exception', 'PhutilAuthUserAbortedException' => 'PhutilAuthException', 'PhutilBacktraceSignalHandler' => 'PhutilSignalHandler', 'PhutilBallOfPHP' => 'Phobject', 'PhutilBitbucketAuthAdapter' => 'PhutilOAuth1AuthAdapter', 'PhutilBootloaderException' => 'Exception', 'PhutilBritishEnglishLocale' => 'PhutilLocale', 'PhutilBufferedIterator' => array( 'Phobject', 'Iterator', ), 'PhutilBufferedIteratorTestCase' => 'PhutilTestCase', 'PhutilBugtraqParser' => 'Phobject', 'PhutilBugtraqParserTestCase' => 'PhutilTestCase', 'PhutilCIDRBlock' => 'Phobject', 'PhutilCIDRList' => 'Phobject', 'PhutilCLikeCodeSnippetContextFreeGrammar' => 'PhutilCodeSnippetContextFreeGrammar', 'PhutilCalendarAbsoluteDateTime' => 'PhutilCalendarDateTime', 'PhutilCalendarContainerNode' => 'PhutilCalendarNode', 'PhutilCalendarDateTime' => 'Phobject', 'PhutilCalendarDocumentNode' => 'PhutilCalendarContainerNode', 'PhutilCalendarDuration' => 'Phobject', 'PhutilCalendarEventNode' => 'PhutilCalendarNode', 'PhutilCalendarNode' => 'Phobject', 'PhutilCalendarProxyDateTime' => 'PhutilCalendarDateTime', 'PhutilCalendarRawNode' => 'PhutilCalendarContainerNode', 'PhutilCalendarRecurrenceList' => 'PhutilCalendarRecurrenceSource', 'PhutilCalendarRecurrenceRule' => 'PhutilCalendarRecurrenceSource', + 'PhutilCalendarRecurrenceRuleTestCase' => 'PhutilTestCase', 'PhutilCalendarRecurrenceSet' => 'Phobject', 'PhutilCalendarRecurrenceSource' => 'Phobject', 'PhutilCalendarRecurrenceTestCase' => 'PhutilTestCase', 'PhutilCalendarRelativeDateTime' => 'PhutilCalendarProxyDateTime', 'PhutilCalendarRootNode' => 'PhutilCalendarContainerNode', 'PhutilCalendarUserNode' => 'PhutilCalendarNode', 'PhutilCallbackFilterIterator' => 'FilterIterator', 'PhutilCallbackSignalHandler' => 'PhutilSignalHandler', 'PhutilChannel' => 'Phobject', 'PhutilChannelChannel' => 'PhutilChannel', 'PhutilChannelTestCase' => 'PhutilTestCase', 'PhutilChunkedIterator' => array( 'Phobject', 'Iterator', ), 'PhutilChunkedIteratorTestCase' => 'PhutilTestCase', 'PhutilClassMapQuery' => 'Phobject', 'PhutilCodeSnippetContextFreeGrammar' => 'PhutilContextFreeGrammar', 'PhutilCommandString' => 'Phobject', 'PhutilConsole' => 'Phobject', 'PhutilConsoleBlock' => 'PhutilConsoleView', 'PhutilConsoleConcatenatedView' => 'PhutilConsoleView', 'PhutilConsoleFormatter' => 'Phobject', 'PhutilConsoleList' => 'PhutilConsoleView', 'PhutilConsoleMessage' => 'Phobject', 'PhutilConsoleProgressBar' => 'Phobject', 'PhutilConsoleServer' => 'Phobject', 'PhutilConsoleServerChannel' => 'PhutilChannelChannel', 'PhutilConsoleStdinNotInteractiveException' => 'Exception', 'PhutilConsoleSyntaxHighlighter' => 'Phobject', 'PhutilConsoleTable' => 'PhutilConsoleView', 'PhutilConsoleView' => 'Phobject', 'PhutilConsoleWrapTestCase' => 'PhutilTestCase', 'PhutilContextFreeGrammar' => 'Phobject', 'PhutilCowsay' => 'Phobject', 'PhutilCowsayTestCase' => 'PhutilTestCase', 'PhutilCsprintfTestCase' => 'PhutilTestCase', 'PhutilCzechLocale' => 'PhutilLocale', 'PhutilDaemon' => 'Phobject', 'PhutilDaemonHandle' => 'Phobject', 'PhutilDaemonOverseer' => 'Phobject', 'PhutilDaemonOverseerModule' => 'Phobject', 'PhutilDefaultSyntaxHighlighter' => 'Phobject', 'PhutilDefaultSyntaxHighlighterEngine' => 'PhutilSyntaxHighlighterEngine', 'PhutilDefaultSyntaxHighlighterEnginePygmentsFuture' => 'FutureProxy', 'PhutilDefaultSyntaxHighlighterEngineTestCase' => 'PhutilTestCase', 'PhutilDeferredLog' => 'Phobject', 'PhutilDeferredLogTestCase' => 'PhutilTestCase', 'PhutilDirectedScalarGraph' => 'AbstractDirectedGraph', 'PhutilDirectoryFixture' => 'Phobject', 'PhutilDirectoryKeyValueCache' => 'PhutilKeyValueCache', 'PhutilDisqusAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilDivinerSyntaxHighlighter' => 'Phobject', 'PhutilDocblockParser' => 'Phobject', 'PhutilDocblockParserTestCase' => 'PhutilTestCase', 'PhutilEditDistanceMatrix' => 'Phobject', 'PhutilEditDistanceMatrixTestCase' => 'PhutilTestCase', 'PhutilEditorConfig' => 'Phobject', 'PhutilEditorConfigTestCase' => 'PhutilTestCase', 'PhutilEmailAddress' => 'Phobject', 'PhutilEmailAddressTestCase' => 'PhutilTestCase', 'PhutilEmojiLocale' => 'PhutilLocale', 'PhutilEmptyAuthAdapter' => 'PhutilAuthAdapter', 'PhutilEnglishCanadaLocale' => 'PhutilLocale', 'PhutilErrorHandler' => 'Phobject', 'PhutilErrorHandlerTestCase' => 'PhutilTestCase', 'PhutilErrorTrap' => 'Phobject', 'PhutilEvent' => 'Phobject', 'PhutilEventConstants' => 'Phobject', 'PhutilEventEngine' => 'Phobject', 'PhutilEventListener' => 'Phobject', 'PhutilEventType' => 'PhutilEventConstants', 'PhutilExampleBufferedIterator' => 'PhutilBufferedIterator', 'PhutilExcessiveServiceCallsDaemon' => 'PhutilTortureTestDaemon', 'PhutilExecChannel' => 'PhutilChannel', 'PhutilExecPassthru' => 'PhutilExecutableFuture', 'PhutilExecutableFuture' => 'Future', 'PhutilExecutionEnvironment' => 'Phobject', 'PhutilExtensionsTestCase' => 'PhutilTestCase', 'PhutilFacebookAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilFatalDaemon' => 'PhutilTortureTestDaemon', 'PhutilFileLock' => 'PhutilLock', 'PhutilFileLockTestCase' => 'PhutilTestCase', 'PhutilFileTree' => 'Phobject', 'PhutilFrenchLocale' => 'PhutilLocale', 'PhutilGermanLocale' => 'PhutilLocale', 'PhutilGitHubAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilGitHubFuture' => 'FutureProxy', 'PhutilGitHubResponse' => 'Phobject', 'PhutilGitURI' => 'Phobject', 'PhutilGitURITestCase' => 'PhutilTestCase', 'PhutilGoogleAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilHTTPEngineExtension' => 'Phobject', 'PhutilHangForeverDaemon' => 'PhutilTortureTestDaemon', 'PhutilHashingIterator' => array( 'PhutilProxyIterator', 'Iterator', ), 'PhutilHashingIteratorTestCase' => 'PhutilTestCase', 'PhutilHelpArgumentWorkflow' => 'PhutilArgumentWorkflow', 'PhutilHgsprintfTestCase' => 'PhutilTestCase', 'PhutilHighIntensityIntervalDaemon' => 'PhutilTortureTestDaemon', 'PhutilICSParser' => 'Phobject', 'PhutilICSParserException' => 'Exception', 'PhutilICSParserTestCase' => 'PhutilTestCase', 'PhutilICSWriter' => 'Phobject', 'PhutilICSWriterTestCase' => 'PhutilTestCase', 'PhutilINIParserException' => 'Exception', 'PhutilIPAddress' => 'Phobject', 'PhutilIPAddressTestCase' => 'PhutilTestCase', 'PhutilInRequestKeyValueCache' => 'PhutilKeyValueCache', 'PhutilInteractiveEditor' => 'Phobject', 'PhutilInvalidRuleParserGeneratorException' => 'PhutilParserGeneratorException', 'PhutilInvalidStateException' => 'Exception', 'PhutilInvalidStateExceptionTestCase' => 'PhutilTestCase', 'PhutilInvisibleSyntaxHighlighter' => 'Phobject', 'PhutilIrreducibleRuleParserGeneratorException' => 'PhutilParserGeneratorException', 'PhutilJIRAAuthAdapter' => 'PhutilOAuth1AuthAdapter', 'PhutilJSON' => 'Phobject', 'PhutilJSONFragmentLexer' => 'PhutilLexer', 'PhutilJSONFragmentLexerHighlighterTestCase' => 'PhutilTestCase', 'PhutilJSONParser' => 'Phobject', 'PhutilJSONParserException' => 'Exception', 'PhutilJSONParserTestCase' => 'PhutilTestCase', 'PhutilJSONProtocolChannel' => 'PhutilProtocolChannel', 'PhutilJSONProtocolChannelTestCase' => 'PhutilTestCase', 'PhutilJSONTestCase' => 'PhutilTestCase', 'PhutilJavaCodeSnippetContextFreeGrammar' => 'PhutilCLikeCodeSnippetContextFreeGrammar', 'PhutilKeyValueCache' => 'Phobject', 'PhutilKeyValueCacheNamespace' => 'PhutilKeyValueCacheProxy', 'PhutilKeyValueCacheProfiler' => 'PhutilKeyValueCacheProxy', 'PhutilKeyValueCacheProxy' => 'PhutilKeyValueCache', 'PhutilKeyValueCacheStack' => 'PhutilKeyValueCache', 'PhutilKeyValueCacheTestCase' => 'PhutilTestCase', 'PhutilKoreanLocale' => 'PhutilLocale', 'PhutilLDAPAuthAdapter' => 'PhutilAuthAdapter', 'PhutilLanguageGuesser' => 'Phobject', 'PhutilLanguageGuesserTestCase' => 'PhutilTestCase', 'PhutilLexer' => 'Phobject', 'PhutilLexerSyntaxHighlighter' => 'PhutilSyntaxHighlighter', 'PhutilLibraryConflictException' => 'Exception', 'PhutilLibraryMapBuilder' => 'Phobject', 'PhutilLibraryTestCase' => 'PhutilTestCase', 'PhutilLipsumContextFreeGrammar' => 'PhutilContextFreeGrammar', 'PhutilLocale' => 'Phobject', 'PhutilLocaleTestCase' => 'PhutilTestCase', 'PhutilLock' => 'Phobject', 'PhutilLockException' => 'Exception', 'PhutilLogFileChannel' => 'PhutilChannelChannel', 'PhutilLunarPhase' => 'Phobject', 'PhutilLunarPhaseTestCase' => 'PhutilTestCase', 'PhutilMarkupEngine' => 'Phobject', 'PhutilMarkupTestCase' => 'PhutilTestCase', 'PhutilMemcacheKeyValueCache' => 'PhutilKeyValueCache', 'PhutilMethodNotImplementedException' => 'Exception', 'PhutilMetricsChannel' => 'PhutilChannelChannel', 'PhutilMissingSymbolException' => 'Exception', 'PhutilModuleUtilsTestCase' => 'PhutilTestCase', 'PhutilNiceDaemon' => 'PhutilTortureTestDaemon', 'PhutilNumber' => 'Phobject', 'PhutilOAuth1AuthAdapter' => 'PhutilAuthAdapter', 'PhutilOAuth1Future' => 'FutureProxy', 'PhutilOAuth1FutureTestCase' => 'PhutilTestCase', 'PhutilOAuthAuthAdapter' => 'PhutilAuthAdapter', 'PhutilOnDiskKeyValueCache' => 'PhutilKeyValueCache', 'PhutilOpaqueEnvelope' => 'Phobject', 'PhutilOpaqueEnvelopeKey' => 'Phobject', 'PhutilOpaqueEnvelopeTestCase' => 'PhutilTestCase', 'PhutilPHPCodeSnippetContextFreeGrammar' => 'PhutilCLikeCodeSnippetContextFreeGrammar', 'PhutilPHPFragmentLexer' => 'PhutilLexer', 'PhutilPHPFragmentLexerHighlighterTestCase' => 'PhutilTestCase', 'PhutilPHPFragmentLexerTestCase' => 'PhutilTestCase', 'PhutilPHPObjectProtocolChannel' => 'PhutilProtocolChannel', 'PhutilPHPObjectProtocolChannelTestCase' => 'PhutilTestCase', 'PhutilParserGenerator' => 'Phobject', 'PhutilParserGeneratorException' => 'Exception', 'PhutilParserGeneratorTestCase' => 'PhutilTestCase', 'PhutilPayPalAPIFuture' => 'FutureProxy', 'PhutilPersonTest' => array( 'Phobject', 'PhutilPerson', ), 'PhutilPersonaAuthAdapter' => 'PhutilAuthAdapter', 'PhutilPhabricatorAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilPhtTestCase' => 'PhutilTestCase', 'PhutilPirateEnglishLocale' => 'PhutilLocale', 'PhutilPortugueseBrazilLocale' => 'PhutilLocale', 'PhutilPortuguesePortugalLocale' => 'PhutilLocale', 'PhutilPregsprintfTestCase' => 'PhutilTestCase', 'PhutilProcessGroupDaemon' => 'PhutilTortureTestDaemon', 'PhutilProseDiff' => 'Phobject', 'PhutilProseDiffTestCase' => 'PhutilTestCase', 'PhutilProseDifferenceEngine' => 'Phobject', 'PhutilProtocolChannel' => 'PhutilChannelChannel', 'PhutilProxyException' => 'Exception', 'PhutilProxyIterator' => array( 'Phobject', 'Iterator', ), 'PhutilPygmentizeParser' => 'Phobject', 'PhutilPygmentizeParserTestCase' => 'PhutilTestCase', 'PhutilPygmentsSyntaxHighlighter' => 'Phobject', 'PhutilPythonFragmentLexer' => 'PhutilLexer', 'PhutilQueryStringParser' => 'Phobject', 'PhutilQueryStringParserTestCase' => 'PhutilTestCase', 'PhutilRainbowSyntaxHighlighter' => 'Phobject', 'PhutilRawEnglishLocale' => 'PhutilLocale', 'PhutilReadableSerializer' => 'Phobject', 'PhutilReadableSerializerTestCase' => 'PhutilTestCase', 'PhutilRealNameContextFreeGrammar' => 'PhutilContextFreeGrammar', 'PhutilRemarkupBlockInterpreter' => 'Phobject', 'PhutilRemarkupBlockRule' => 'Phobject', 'PhutilRemarkupBlockStorage' => 'Phobject', 'PhutilRemarkupBoldRule' => 'PhutilRemarkupRule', 'PhutilRemarkupCodeBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupDefaultBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupDelRule' => 'PhutilRemarkupRule', 'PhutilRemarkupDocumentLinkRule' => 'PhutilRemarkupRule', 'PhutilRemarkupEngine' => 'PhutilMarkupEngine', 'PhutilRemarkupEngineTestCase' => 'PhutilTestCase', 'PhutilRemarkupEscapeRemarkupRule' => 'PhutilRemarkupRule', 'PhutilRemarkupHeaderBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupHighlightRule' => 'PhutilRemarkupRule', 'PhutilRemarkupHorizontalRuleBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupHyperlinkRule' => 'PhutilRemarkupRule', 'PhutilRemarkupInlineBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupInterpreterBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupItalicRule' => 'PhutilRemarkupRule', 'PhutilRemarkupLinebreaksRule' => 'PhutilRemarkupRule', 'PhutilRemarkupListBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupLiteralBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupMonospaceRule' => 'PhutilRemarkupRule', 'PhutilRemarkupNoteBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupQuotesBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupReplyBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupRule' => 'Phobject', 'PhutilRemarkupSimpleTableBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupTableBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupTestInterpreterRule' => 'PhutilRemarkupBlockInterpreter', 'PhutilRemarkupUnderlineRule' => 'PhutilRemarkupRule', 'PhutilRope' => 'Phobject', 'PhutilRopeTestCase' => 'PhutilTestCase', 'PhutilSafeHTML' => 'Phobject', 'PhutilSafeHTMLTestCase' => 'PhutilTestCase', 'PhutilSaturateStdoutDaemon' => 'PhutilTortureTestDaemon', 'PhutilServiceProfiler' => 'Phobject', 'PhutilShellLexer' => 'PhutilLexer', 'PhutilShellLexerTestCase' => 'PhutilTestCase', 'PhutilSignalHandler' => 'Phobject', 'PhutilSignalRouter' => 'Phobject', 'PhutilSimpleOptions' => 'Phobject', 'PhutilSimpleOptionsLexer' => 'PhutilLexer', 'PhutilSimpleOptionsLexerTestCase' => 'PhutilTestCase', 'PhutilSimpleOptionsTestCase' => 'PhutilTestCase', 'PhutilSimplifiedChineseLocale' => 'PhutilLocale', 'PhutilSlackAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilSlackFuture' => 'FutureProxy', 'PhutilSocketChannel' => 'PhutilChannel', 'PhutilSortVector' => 'Phobject', 'PhutilSpanishSpainLocale' => 'PhutilLocale', 'PhutilSprite' => 'Phobject', 'PhutilSpriteSheet' => 'Phobject', 'PhutilStreamIterator' => array( 'Phobject', 'Iterator', ), 'PhutilSyntaxHighlighter' => 'Phobject', 'PhutilSyntaxHighlighterEngine' => 'Phobject', 'PhutilSyntaxHighlighterException' => 'Exception', 'PhutilSystem' => 'Phobject', 'PhutilSystemTestCase' => 'PhutilTestCase', 'PhutilTerminalString' => 'Phobject', 'PhutilTestPhobject' => 'Phobject', 'PhutilTortureTestDaemon' => 'PhutilDaemon', 'PhutilTraditionalChineseLocale' => 'PhutilLocale', 'PhutilTranslation' => 'Phobject', 'PhutilTranslationTestCase' => 'PhutilTestCase', 'PhutilTranslator' => 'Phobject', 'PhutilTranslatorTestCase' => 'PhutilTestCase', 'PhutilTsprintfTestCase' => 'PhutilTestCase', 'PhutilTwitchAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilTwitchFuture' => 'FutureProxy', 'PhutilTwitterAuthAdapter' => 'PhutilOAuth1AuthAdapter', 'PhutilTypeCheckException' => 'Exception', 'PhutilTypeExtraParametersException' => 'Exception', 'PhutilTypeLexer' => 'PhutilLexer', 'PhutilTypeMissingParametersException' => 'Exception', 'PhutilTypeSpec' => 'Phobject', 'PhutilTypeSpecTestCase' => 'PhutilTestCase', 'PhutilURI' => 'Phobject', 'PhutilURITestCase' => 'PhutilTestCase', 'PhutilUSEnglishLocale' => 'PhutilLocale', 'PhutilUTF8StringTruncator' => 'Phobject', 'PhutilUTF8TestCase' => 'PhutilTestCase', 'PhutilUnknownSymbolParserGeneratorException' => 'PhutilParserGeneratorException', 'PhutilUnreachableRuleParserGeneratorException' => 'PhutilParserGeneratorException', 'PhutilUnreachableTerminalParserGeneratorException' => 'PhutilParserGeneratorException', 'PhutilUrisprintfTestCase' => 'PhutilTestCase', 'PhutilUtilsTestCase' => 'PhutilTestCase', 'PhutilVeryWowEnglishLocale' => 'PhutilLocale', 'PhutilWordPressAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilWordPressFuture' => 'FutureProxy', 'PhutilXHPASTBinary' => 'Phobject', 'PhutilXHPASTSyntaxHighlighter' => 'Phobject', 'PhutilXHPASTSyntaxHighlighterFuture' => 'FutureProxy', 'PhutilXHPASTSyntaxHighlighterTestCase' => 'PhutilTestCase', 'QueryFuture' => 'Future', 'TempFile' => 'Phobject', 'TestAbstractDirectedGraph' => 'AbstractDirectedGraph', 'XHPASTNode' => 'AASTNode', 'XHPASTNodeTestCase' => 'PhutilTestCase', 'XHPASTSyntaxErrorException' => 'Exception', 'XHPASTToken' => 'AASTToken', 'XHPASTTree' => 'AASTTree', 'XHPASTTreeTestCase' => 'PhutilTestCase', 'XsprintfUnknownConversionException' => 'InvalidArgumentException', ), )); diff --git a/src/future/exec/__tests__/ExecFutureTestCase.php b/src/future/exec/__tests__/ExecFutureTestCase.php index 002152e8..2ffe294c 100644 --- a/src/future/exec/__tests__/ExecFutureTestCase.php +++ b/src/future/exec/__tests__/ExecFutureTestCase.php @@ -1,159 +1,147 @@ write('')->resolvex(); $this->assertEqual('', $stdout); } public function testKeepPipe() { // NOTE: This is mostly testing the semantics of $keep_pipe in write(). list($stdout) = id(new ExecFuture('cat')) ->write('', true) ->start() ->write('x', true) ->write('y', true) ->write('z', false) ->resolvex(); $this->assertEqual('xyz', $stdout); } public function testLargeBuffer() { // NOTE: This is mostly a coverage test to hit branches where we're still // flushing a buffer. $data = str_repeat('x', 1024 * 1024 * 4); list($stdout) = id(new ExecFuture('cat'))->write($data)->resolvex(); $this->assertEqual($data, $stdout); } public function testBufferLimit() { $data = str_repeat('x', 1024 * 1024); list($stdout) = id(new ExecFuture('cat')) ->setStdoutSizeLimit(1024) ->write($data) ->resolvex(); $this->assertEqual(substr($data, 0, 1024), $stdout); } public function testResolveTimeoutTestShouldRunLessThan1Sec() { // NOTE: This tests interactions between the resolve() timeout and the // ExecFuture timeout, which are similar but not identical. $future = id(new ExecFuture('sleep 32000'))->start(); $future->setTimeout(32000); // We expect this to return in 0.01s. $result = $future->resolve(0.01); $this->assertEqual($result, null); // We expect this to now force the time out / kill immediately. If we don't // do this, we'll hang when exiting until our subprocess exits (32000 // seconds!) $future->setTimeout(0.01); $future->resolve(); } public function testTimeoutTestShouldRunLessThan1Sec() { // NOTE: This is partly testing that we choose appropriate select wait // times; this test should run for significantly less than 1 second. $future = new ExecFuture('sleep 32000'); list($err) = $future->setTimeout(0.01)->resolve(); $this->assertTrue($err > 0); $this->assertTrue($future->getWasKilledByTimeout()); } public function testMultipleTimeoutsTestShouldRunLessThan1Sec() { $futures = array(); for ($ii = 0; $ii < 4; $ii++) { $futures[] = id(new ExecFuture('sleep 32000'))->setTimeout(0.01); } foreach (new FutureIterator($futures) as $future) { list($err) = $future->resolve(); $this->assertTrue($err > 0); $this->assertTrue($future->getWasKilledByTimeout()); } } - public function testNoHangOnExecFutureDestructionWithRunningChild() { - $start = microtime(true); - $future = new ExecFuture('sleep 30'); - $future->start(); - unset($future); - $end = microtime(true); - - // If ExecFuture::__destruct() hangs until the child closes, we won't make - // it here in time. - $this->assertTrue(($end - $start) < 5); - } - public function testMultipleResolves() { // It should be safe to call resolve(), resolvex(), resolveKill(), etc., // as many times as you want on the same process. $future = new ExecFuture('echo quack'); $future->resolve(); $future->resolvex(); list($err) = $future->resolveKill(); $this->assertEqual(0, $err); } public function testReadBuffering() { $str_len_8 = 'abcdefgh'; $str_len_4 = 'abcd'; // This is a write/read with no read buffer. $future = new ExecFuture('cat'); $future->write($str_len_8); do { $future->isReady(); list($read) = $future->read(); if (strlen($read)) { break; } } while (true); // We expect to get the entire string back in the read. $this->assertEqual($str_len_8, $read); $future->resolve(); // This is a write/read with a read buffer. $future = new ExecFuture('cat'); $future->write($str_len_8); // Set the read buffer size. $future->setReadBufferSize(4); do { $future->isReady(); list($read) = $future->read(); if (strlen($read)) { break; } } while (true); // We expect to get the entire string back in the read. $this->assertEqual($str_len_4, $read); // Remove the limit so we can resolve the future. $future->setReadBufferSize(null); $future->resolve(); } } diff --git a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php index 9754c6d2..f94dcbaa 100644 --- a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php +++ b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php @@ -1,142 +1,1572 @@ frequency = $frequency; - return $this; + private $cursorSecond; + private $cursorMinute; + private $cursorHour; + private $cursorHourState; + private $cursorWeek; + private $cursorWeekday; + private $cursorWeekState; + private $cursorDay; + private $cursorDayState; + private $cursorMonth; + private $cursorYear; + + private $setSeconds; + private $setMinutes; + private $setHours; + private $setDays; + private $setMonths; + private $setWeeks; + private $setYears; + + private $stateSecond; + private $stateMinute; + private $stateHour; + private $stateDay; + private $stateWeek; + private $stateMonth; + private $stateYear; + + private $baseYear; + private $isAllDay; + private $activeSet = array(); + private $nextSet = array(); + private $minimumEpoch; + + const FREQUENCY_SECONDLY = 'SECONDLY'; + const FREQUENCY_MINUTELY = 'MINUTELY'; + const FREQUENCY_HOURLY = 'HOURLY'; + const FREQUENCY_DAILY = 'DAILY'; + const FREQUENCY_WEEKLY = 'WEEKLY'; + const FREQUENCY_MONTHLY = 'MONTHLY'; + const FREQUENCY_YEARLY = 'YEARLY'; + + const SCALE_SECONDLY = 1; + const SCALE_MINUTELY = 2; + const SCALE_HOURLY = 3; + const SCALE_DAILY = 4; + const SCALE_WEEKLY = 5; + const SCALE_MONTHLY = 6; + const SCALE_YEARLY = 7; + + const WEEKDAY_SUNDAY = 'SU'; + const WEEKDAY_MONDAY = 'MO'; + const WEEKDAY_TUESDAY = 'TU'; + const WEEKDAY_WEDNESDAY = 'WE'; + const WEEKDAY_THURSDAY = 'TH'; + const WEEKDAY_FRIDAY = 'FR'; + const WEEKDAY_SATURDAY = 'SA'; + + const WEEKINDEX_SUNDAY = 0; + const WEEKINDEX_MONDAY = 1; + const WEEKINDEX_TUESDAY = 2; + const WEEKINDEX_WEDNESDAY = 3; + const WEEKINDEX_THURSDAY = 4; + const WEEKINDEX_FRIDAY = 5; + const WEEKINDEX_SATURDAY = 6; + + private static function getAllWeekdayConstants() { + return array_keys(self::getWeekdayIndexMap()); } - public function getFrequency() { - return $this->frequency; + private static function getWeekdayIndexMap() { + static $map = array( + self::WEEKDAY_SUNDAY => self::WEEKINDEX_SUNDAY, + self::WEEKDAY_MONDAY => self::WEEKINDEX_MONDAY, + self::WEEKDAY_TUESDAY => self::WEEKINDEX_TUESDAY, + self::WEEKDAY_WEDNESDAY => self::WEEKINDEX_WEDNESDAY, + self::WEEKDAY_THURSDAY => self::WEEKINDEX_THURSDAY, + self::WEEKDAY_FRIDAY => self::WEEKINDEX_FRIDAY, + self::WEEKDAY_SATURDAY => self::WEEKINDEX_SATURDAY, + ); + + return $map; } - public function setUntil(PhutilCalendarDateTime $until) { - $this->until = $until; + private static function getWeekdayIndex($weekday) { + $map = self::getWeekdayIndexMap(); + if (!isset($map[$weekday])) { + $constants = array_keys($map); + throw new Exception( + pht( + 'Weekday "%s" is not a valid weekday constant. Valid constants '. + 'are: %s.', + $weekday, + implode(', ', $constants))); + } + + return $map[$weekday]; + } + + public function setStartDateTime(PhutilCalendarDateTime $start) { + $this->startDateTime = $start; return $this; } - public function getUntil() { - return $this->until; + public function getStartDateTime() { + return $this->startDateTime; } - public function setCount($count) { - $this->count = $count; + public function setFrequency($frequency) { + static $map = array( + self::FREQUENCY_SECONDLY => self::SCALE_SECONDLY, + self::FREQUENCY_MINUTELY => self::SCALE_MINUTELY, + self::FREQUENCY_HOURLY => self::SCALE_HOURLY, + self::FREQUENCY_DAILY => self::SCALE_DAILY, + self::FREQUENCY_WEEKLY => self::SCALE_WEEKLY, + self::FREQUENCY_MONTHLY => self::SCALE_MONTHLY, + self::FREQUENCY_YEARLY => self::SCALE_YEARLY, + ); + + if (empty($map[$frequency])) { + throw new Exception( + pht( + 'RRULE FREQ "%s" is invalid. Valid frequencies are: %s.', + $frequency, + implode(', ', array_keys($map)))); + } + + $this->frequency = $frequency; + $this->frequencyScale = $map[$frequency]; + return $this; } - public function getCount() { - return $this->count; + public function getFrequency() { + return $this->frequency; + } + + public function getFrequencyScale() { + return $this->frequencyScale; } public function setInterval($interval) { + if (!is_int($interval)) { + throw new Exception( + pht( + 'RRULE INTERVAL "%s" is invalid: interval must be an integer.', + $interval)); + } + + if ($interval < 1) { + throw new Exception( + pht( + 'RRULE INTERVAL "%s" is invalid: interval must be 1 or more.', + $interval)); + } + $this->interval = $interval; return $this; } public function getInterval() { return $this->interval; } - public function setBySecond($by_second) { - $this->bySecond = $by_second; + public function setBySecond(array $by_second) { + $this->assertByRange('BYSECOND', $by_second, 0, 60); + $this->bySecond = array_fuse($by_second); return $this; } public function getBySecond() { return $this->bySecond; } - public function setByMinute($by_minute) { - $this->byMinute = $by_minute; + public function setByMinute(array $by_minute) { + $this->assertByRange('BYMINUTE', $by_minute, 0, 59); + $this->byMinute = array_fuse($by_minute); return $this; } public function getByMinute() { return $this->byMinute; } - public function setByHour($by_hour) { - $this->byHour = $by_hour; + public function setByHour(array $by_hour) { + $this->assertByRange('BYHOUR', $by_hour, 0, 23); + $this->byHour = array_fuse($by_hour); return $this; } public function getByHour() { return $this->byHour; } - public function setByDay($by_day) { - $this->byDay = $by_day; + public function setByDay(array $by_day) { + $constants = self::getAllWeekdayConstants(); + $constants = implode('|', $constants); + + $pattern = '/^(?:[+-]?([1-9]\d?))?('.$constants.')\z/'; + foreach ($by_day as $key => $value) { + $matches = null; + if (!preg_match($pattern, $value, $matches)) { + throw new Exception( + pht( + 'RRULE BYDAY value "%s" is invalid: rule part must be in the '. + 'expected form (like "MO", "-3TH", or "+2SU").', + $value)); + } + + // The maximum allowed value is 53, which corresponds to "the 53rd + // Monday every year" or similar when evaluated against a YEARLY rule. + + $maximum = 53; + $magnitude = (int)$matches[1]; + if ($magnitude > $maximum) { + throw new Exception( + pht( + 'RRULE BYDAY value "%s" has an offset with magnitude "%s", but '. + 'the maximum permitted value is "%s".', + $value, + $magnitude, + $maximum)); + } + + // Normalize "+3FR" into "3FR". + $by_day[$key] = ltrim($value, '+'); + } + + $this->byDay = array_fuse($by_day); return $this; } public function getByDay() { return $this->byDay; } - public function setByMonthDay($by_month_day) { - $this->byMonthDay = $by_month_day; + public function setByMonthDay(array $by_month_day) { + $this->assertByRange('BYMONTHDAY', $by_month_day, -31, 31, false); + $this->byMonthDay = array_fuse($by_month_day); return $this; } public function getByMonthDay() { return $this->byMonthDay; } public function setByYearDay($by_year_day) { - $this->byYearDay = $by_year_day; + $this->assertByRange('BYYEARDAY', $by_year_day, -366, 366, false); + $this->byYearDay = array_fuse($by_year_day); return $this; } public function getByYearDay() { return $this->byYearDay; } - public function setByMonth($by_month) { - $this->byMonth = $by_month; + public function setByMonth(array $by_month) { + $this->assertByRange('BYMONTH', $by_month, 1, 12); + $this->byMonth = array_fuse($by_month); return $this; } public function getByMonth() { return $this->byMonth; } - public function setBySetPosition($by_set_position) { + public function setByWeekNumber(array $by_week_number) { + $this->assertByRange('BYWEEKNO', $by_week_number, -53, 53, false); + $this->byWeekNumber = array_fuse($by_week_number); + return $this; + } + + public function getByWeekNumber() { + return $this->byWeekNumber; + } + + public function setBySetPosition(array $by_set_position) { + $this->assertByRange('BYSETPOS', $by_set_position, -366, 366, false); $this->bySetPosition = $by_set_position; return $this; } public function getBySetPosition() { return $this->bySetPosition; } public function setWeekStart($week_start) { + // Make sure this is a valid weekday constant. + self::getWeekdayIndex($week_start); + $this->weekStart = $week_start; return $this; } public function getWeekStart() { return $this->weekStart; } + public function resetSource() { + $frequency = $this->getFrequency(); + + if ($this->getByMonthDay()) { + switch ($frequency) { + case self::FREQUENCY_WEEKLY: + // RFC5545: "The BYMONTHDAY rule part MUST NOT be specified when the + // FREQ rule part is set to WEEKLY." + throw new Exception( + pht( + 'RRULE specifies BYMONTHDAY with FREQ set to WEEKLY, which '. + 'violates RFC5545.')); + break; + default: + break; + } + + } + + if ($this->getByYearDay()) { + switch ($frequency) { + case self::FREQUENCY_DAILY: + case self::FREQUENCY_WEEKLY: + case self::FREQUENCY_MONTHLY: + // RFC5545: "The BYYEARDAY rule part MUST NOT be specified when the + // FREQ rule part is set to DAILY, WEEKLY, or MONTHLY." + throw new Exception( + pht( + 'RRULE specifies BYYEARDAY with FREQ of DAILY, WEEKLY or '. + 'MONTHLY, which violates RFC5545.')); + default: + break; + } + } + + // TODO + // RFC5545: "The BYDAY rule part MUST NOT be specified with a numeric + // value when the FREQ rule part is not set to MONTHLY or YEARLY." + // RFC5545: "Furthermore, the BYDAY rule part MUST NOT be specified with a + // numeric value with the FREQ rule part set to YEARLY when the BYWEEKNO + // rule part is specified." + + + $date = $this->getStartDateTime(); + + $this->cursorSecond = $date->getSecond(); + $this->cursorMinute = $date->getMinute(); + $this->cursorHour = $date->getHour(); + + $this->cursorDay = $date->getDay(); + $this->cursorMonth = $date->getMonth(); + $this->cursorYear = $date->getYear(); + + $year_map = $this->getYearMap($this->cursorYear, $this->getWeekStart()); + $key = $this->cursorMonth.'M'.$this->cursorDay.'D'; + $this->cursorWeek = $year_map['info'][$key]['week']; + $this->cursorWeekday = $year_map['info'][$key]['weekday']; + + $this->setSeconds = array(); + $this->setMinutes = array(); + $this->setHours = array(); + $this->setDays = array(); + $this->setMonths = array(); + $this->setYears = array(); + + $this->stateSecond = null; + $this->stateMinute = null; + $this->stateHour = null; + $this->stateDay = null; + $this->stateWeek = null; + $this->stateMonth = null; + $this->stateYear = null; + + // If we have a BYSETPOS, we need to generate the entire set before we + // can filter it and return results. Normally, we start generating at + // the start date, but we need to go back one interval to generate + // BYSETPOS events so we can make sure the entire set is generated. + if ($this->getBySetPosition()) { + $interval = $this->getInterval(); + switch ($frequency) { + case self::FREQUENCY_YEARLY: + $this->cursorYear -= $interval; + break; + case self::FREQUENCY_MONTHLY: + $this->cursorMonth -= $interval; + $this->rewindMonth(); + break; + case self::FREQUENCY_WEEKLY: + $this->cursorWeek -= $interval; + $this->rewindWeek(); + break; + case self::FREQUENCY_DAILY: + $this->cursorDay -= $interval; + $this->rewindDay(); + break; + case self::FREQUENCY_HOURLY: + $this->cursorHour -= $interval; + $this->rewindHour(); + break; + case self::FREQUENCY_MINUTELY: + $this->cursorMinute -= $interval; + $this->rewindMinute(); + break; + case self::FREQUENCY_SECONDLY: + default: + throw new Exception( + pht( + 'RRULE specifies BYSETPOS with FREQ "%s", but this is invalid.', + $frequency)); + } + } + + // We can generate events from before the cursor when evaluating rules + // with BYSETPOS or FREQ=WEEKLY. + $this->minimumEpoch = $this->getStartDateTime()->getEpoch(); + + $cursor_state = array( + 'year' => $this->cursorYear, + 'month' => $this->cursorMonth, + 'week' => $this->cursorWeek, + 'day' => $this->cursorDay, + 'hour' => $this->cursorHour, + ); + + $this->cursorDayState = $cursor_state; + $this->cursorWeekState = $cursor_state; + $this->cursorHourState = $cursor_state; + + $by_hour = $this->getByHour(); + $by_minute = $this->getByMinute(); + $by_second = $this->getBySecond(); + + $scale = $this->getFrequencyScale(); + + // We return all-day events if the start date is an all-day event and we + // don't have more granular selectors or a more granular frequency. + $this->isAllDay = $date->getIsAllDay() + && !$by_hour + && !$by_minute + && !$by_second + && ($scale > self::SCALE_HOURLY); + } + public function getNextEvent($cursor) { - throw new PhutilMethodNotImplementedException(); + while (true) { + $event = $this->generateNextEvent(); + if (!$event) { + break; + } + + $epoch = $event->getEpoch(); + if ($this->minimumEpoch) { + if ($epoch < $this->minimumEpoch) { + continue; + } + } + + if ($epoch < $cursor) { + continue; + } + + break; + } + + return $event; + } + + private function generateNextEvent() { + if ($this->activeSet) { + return array_pop($this->activeSet); + } + + $this->baseYear = $this->cursorYear; + + $by_setpos = $this->getBySetPosition(); + if ($by_setpos) { + $old_state = $this->getSetPositionState(); + } + + while (!$this->activeSet) { + $this->activeSet = $this->nextSet; + $this->nextSet = array(); + + while (true) { + if ($this->isAllDay) { + $this->nextDay(); + } else { + $this->nextSecond(); + } + + $result = id(new PhutilCalendarAbsoluteDateTime()) + ->setViewerTimezone($this->getViewerTimezone()) + ->setYear($this->stateYear) + ->setMonth($this->stateMonth) + ->setDay($this->stateDay); + + if ($this->isAllDay) { + $result->setIsAllDay(true); + } else { + $result + ->setHour($this->stateHour) + ->setMinute($this->stateMinute) + ->setSecond($this->stateSecond); + } + + // If we don't have BYSETPOS, we're all done. We put this into the + // set and will immediately return it. + if (!$by_setpos) { + $this->activeSet[] = $result; + break; + } + + // Otherwise, check if we've completed a set. The set is complete if + // the state has moved past the span we were examining (for example, + // with a YEARLY event, if the state is now in the next year). + $new_state = $this->getSetPositionState(); + if ($new_state == $old_state) { + $this->activeSet[] = $result; + continue; + } + + $this->activeSet = $this->applySetPos($this->activeSet, $by_setpos); + $this->activeSet = array_reverse($this->activeSet); + $this->nextSet[] = $result; + $old_state = $new_state; + break; + } + } + + return array_pop($this->activeSet); + } + + + protected function nextSecond() { + if ($this->setSeconds) { + $this->stateSecond = array_pop($this->setSeconds); + return; + } + + $frequency = $this->getFrequency(); + $interval = $this->getInterval(); + $is_secondly = ($frequency == self::FREQUENCY_SECONDLY); + $by_second = $this->getBySecond(); + + while (!$this->setSeconds) { + $this->nextMinute(); + + if ($is_secondly || $by_second) { + $seconds = $this->newSecondsSet( + ($is_secondly ? $interval : 1), + $by_second); + } else { + $seconds = array( + $this->cursorSecond, + ); + } + + $this->setSeconds = array_reverse($seconds); + } + + $this->stateSecond = array_pop($this->setSeconds); + } + + protected function nextMinute() { + if ($this->setMinutes) { + $this->stateMinute = array_pop($this->setMinutes); + return; + } + + $frequency = $this->getFrequency(); + $interval = $this->getInterval(); + $scale = $this->getFrequencyScale(); + $is_minutely = ($frequency === self::FREQUENCY_MINUTELY); + $by_minute = $this->getByMinute(); + + while (!$this->setMinutes) { + $this->nextHour(); + + if ($is_minutely || $by_minute) { + $minutes = $this->newMinutesSet( + ($is_minutely ? $interval : 1), + $by_minute); + } else if ($scale < self::SCALE_MINUTELY) { + $minutes = $this->newMinutesSet( + 1, + array()); + } else { + $minutes = array( + $this->cursorMinute, + ); + } + + $this->setMinutes = array_reverse($minutes); + } + + $this->stateMinute = array_pop($this->setMinutes); + } + + protected function nextHour() { + if ($this->setHours) { + $this->stateHour = array_pop($this->setHours); + return; + } + + $frequency = $this->getFrequency(); + $interval = $this->getInterval(); + $scale = $this->getFrequencyScale(); + $is_hourly = ($frequency === self::FREQUENCY_HOURLY); + $by_hour = $this->getByHour(); + + while (!$this->setHours) { + $this->nextDay(); + + $is_dynamic = $is_hourly + || $by_hour + || ($scale < self::SCALE_HOURLY); + + if ($is_dynamic) { + $hours = $this->newHoursSet( + ($is_hourly ? $interval : 1), + $by_hour); + } else { + $hours = array( + $this->cursorHour, + ); + } + + $this->setHours = array_reverse($hours); + } + + $this->stateHour = array_pop($this->setHours); + } + + protected function nextDay() { + if ($this->setDays) { + $info = array_pop($this->setDays); + $this->setDayState($info); + return; + } + + $frequency = $this->getFrequency(); + $interval = $this->getInterval(); + $scale = $this->getFrequencyScale(); + $is_daily = ($frequency === self::FREQUENCY_DAILY); + $is_weekly = ($frequency === self::FREQUENCY_WEEKLY); + + $by_day = $this->getByDay(); + $by_monthday = $this->getByMonthDay(); + $by_yearday = $this->getByYearDay(); + $by_weekno = $this->getByWeekNumber(); + $by_month = $this->getByMonth(); + $week_start = $this->getWeekStart(); + + while (!$this->setDays) { + if ($is_weekly) { + $this->nextWeek(); + } else { + $this->nextMonth(); + } + + // NOTE: We normally handle BYMONTH when iterating months, but it acts + // like a filter if FREQ=WEEKLY. + + $is_dynamic = $is_daily + || $is_weekly + || $by_day + || $by_monthday + || $by_yearday + || $by_weekno + || ($by_month && $is_weekly) + || ($scale < self::SCALE_DAILY); + + if ($is_dynamic) { + $weeks = $this->newDaysSet( + ($is_daily ? $interval : 1), + $by_day, + $by_monthday, + $by_yearday, + $by_weekno, + $by_month, + $week_start); + } else { + // The cursor day may not actually exist in the current month, so + // make sure the day is valid before we generate a set which contains + // it. + $year_map = $this->getYearMap($this->stateYear, $week_start); + if ($this->cursorDay > $year_map['monthDays'][$this->stateMonth]) { + $weeks = array( + array(), + ); + } else { + $key = $this->stateMonth.'M'.$this->cursorDay.'D'; + $weeks = array( + array($year_map['info'][$key]), + ); + } + } + + // Unpack the weeks into days. + $days = array_mergev($weeks); + + $this->setDays = array_reverse($days); + } + + $info = array_pop($this->setDays); + $this->setDayState($info); + } + + private function setDayState(array $info) { + $this->stateDay = $info['monthday']; + $this->stateWeek = $info['week']; + $this->stateMonth = $info['month']; + } + + protected function nextMonth() { + if ($this->setMonths) { + $this->stateMonth = array_pop($this->setMonths); + return; + } + + $frequency = $this->getFrequency(); + $interval = $this->getInterval(); + $scale = $this->getFrequencyScale(); + $is_monthly = ($frequency === self::FREQUENCY_MONTHLY); + + $by_month = $this->getByMonth(); + + // If we have a BYMONTHDAY, we consider that set of days in every month. + // For example, "FREQ=YEARLY;BYMONTHDAY=3" means "the third day of every + // month", so we need to expand the month set if the constraint is present. + $by_monthday = $this->getByMonthDay(); + + // Likewise, we need to generate all months if we have BYYEARDAY or + // BYWEEKNO or BYDAY. + $by_yearday = $this->getByYearDay(); + $by_weekno = $this->getByWeekNumber(); + $by_day = $this->getByDay(); + + while (!$this->setMonths) { + $this->nextYear(); + + $is_dynamic = $is_monthly + || $by_month + || $by_monthday + || $by_yearday + || $by_weekno + || $by_day + || ($scale < self::SCALE_MONTHLY); + + if ($is_dynamic) { + $months = $this->newMonthsSet( + ($is_monthly ? $interval : 1), + $by_month); + } else { + $months = array( + $this->cursorMonth, + ); + } + + $this->setMonths = array_reverse($months); + } + + $this->stateMonth = array_pop($this->setMonths); + } + + protected function nextWeek() { + if ($this->setWeeks) { + $this->stateWeek = array_pop($this->setWeeks); + return; + } + + $frequency = $this->getFrequency(); + $interval = $this->getInterval(); + $scale = $this->getFrequencyScale(); + $by_weekno = $this->getByWeekNumber(); + + while (!$this->setWeeks) { + $this->nextYear(); + + $weeks = $this->newWeeksSet( + $interval, + $by_weekno); + + $this->setWeeks = array_reverse($weeks); + } + + $this->stateWeek = array_pop($this->setWeeks); + } + + protected function nextYear() { + $this->stateYear = $this->cursorYear; + + $frequency = $this->getFrequency(); + $is_yearly = ($frequency === self::FREQUENCY_YEARLY); + + if ($is_yearly) { + $interval = $this->getInterval(); + } else { + $interval = 1; + } + + $this->cursorYear = $this->cursorYear + $interval; + + if ($this->cursorYear > ($this->baseYear + 100)) { + throw new Exception( + pht( + 'RRULE evaluation failed to generate more events in the next 100 '. + 'years. This RRULE is likely invalid or degenerate.')); + } + + } + + private function newSecondsSet($interval, $set) { + // TODO: This doesn't account for leap seconds. In theory, it probably + // should, although this shouldn't impact any real events. + $seconds_in_minute = 60; + + if ($this->cursorSecond >= $seconds_in_minute) { + $this->cursorSecond -= $seconds_in_minute; + return array(); + } + + list($cursor, $result) = $this->newIteratorSet( + $this->cursorSecond, + $interval, + $set, + $seconds_in_minute); + + $this->cursorSecond = ($cursor - $seconds_in_minute); + + return $result; + } + + private function newMinutesSet($interval, $set) { + // NOTE: This value is legitimately a constant! Amazing! + $minutes_in_hour = 60; + + if ($this->cursorMinute >= $minutes_in_hour) { + $this->cursorMinute -= $minutes_in_hour; + return array(); + } + + list($cursor, $result) = $this->newIteratorSet( + $this->cursorMinute, + $interval, + $set, + $minutes_in_hour); + + $this->cursorMinute = ($cursor - $minutes_in_hour); + + return $result; + } + + private function newHoursSet($interval, $set) { + // TODO: This doesn't account for hours caused by daylight savings time. + // It probably should, although this seems unlikely to impact any real + // events. + $hours_in_day = 24; + + // If the hour cursor is behind the current time, we need to forward it in + // INTERVAL increments so we end up with the right offset. + list($skip, $this->cursorHourState) = $this->advanceCursorState( + $this->cursorHourState, + self::SCALE_HOURLY, + $interval, + $this->getWeekStart()); + + if ($skip) { + return array(); + } + + list($cursor, $result) = $this->newIteratorSet( + $this->cursorHour, + $interval, + $set, + $hours_in_day); + + $this->cursorHour = ($cursor - $hours_in_day); + + return $result; + } + + private function newWeeksSet($interval, $set) { + $week_start = $this->getWeekStart(); + + list($skip, $this->cursorWeekState) = $this->advanceCursorState( + $this->cursorWeekState, + self::SCALE_WEEKLY, + $interval, + $week_start); + + if ($skip) { + return array(); + } + + $year_map = $this->getYearMap($this->stateYear, $week_start); + + $result = array(); + while (true) { + if (!isset($year_map['weekMap'][$this->cursorWeek])) { + break; + } + $result[] = $this->cursorWeek; + $this->cursorWeek += $interval; + } + + $this->cursorWeek -= $year_map['weekCount']; + + return $result; + } + + private function newDaysSet( + $interval_day, + $by_day, + $by_monthday, + $by_yearday, + $by_weekno, + $by_month, + $week_start) { + + $frequency = $this->getFrequency(); + $is_yearly = ($frequency == self::FREQUENCY_YEARLY); + $is_monthly = ($frequency == self::FREQUENCY_MONTHLY); + $is_weekly = ($frequency == self::FREQUENCY_WEEKLY); + + $selection = array(); + if ($is_weekly) { + $year_map = $this->getYearMap($this->stateYear, $week_start); + + if (isset($year_map['weekMap'][$this->stateWeek])) { + foreach ($year_map['weekMap'][$this->stateWeek] as $key) { + $selection[] = $year_map['info'][$key]; + } + } + } else { + // If the day cursor is behind the current year and month, we need to + // forward it in INTERVAL increments so we end up with the right offset + // in the current month. + list($skip, $this->cursorDayState) = $this->advanceCursorState( + $this->cursorDayState, + self::SCALE_DAILY, + $interval_day, + $week_start); + + if (!$skip) { + $year_map = $this->getYearMap($this->stateYear, $week_start); + while (true) { + $month_idx = $this->stateMonth; + $month_days = $year_map['monthDays'][$month_idx]; + if ($this->cursorDay > $month_days) { + // NOTE: The year map is now out of date, but we're about to break + // out of the loop anyway so it doesn't matter. + break; + } + + $day_idx = $this->cursorDay; + + $key = "{$month_idx}M{$day_idx}D"; + $selection[] = $year_map['info'][$key]; + + $this->cursorDay += $interval_day; + } + } + } + + // As a special case, BYDAY applies to relative month offsets if BYMONTH + // is present in a YEARLY rule. + if ($is_yearly) { + if ($this->getByMonth()) { + $is_yearly = false; + $is_monthly = true; + } + } + + // As a special case, BYDAY makes us examine all week days. This doesn't + // check BYMONTHDAY or BYYEARDAY because they are not valid with WEEKLY. + $filter_weekday = true; + if ($is_weekly) { + if ($by_day) { + $filter_weekday = false; + } + } + + $weeks = array(); + foreach ($selection as $key => $info) { + if ($is_weekly) { + if ($filter_weekday) { + if ($info['weekday'] != $this->cursorWeekday) { + continue; + } + } + } else { + if ($info['month'] != $this->stateMonth) { + continue; + } + } + + if ($by_day) { + if (empty($by_day[$info['weekday']])) { + if ($is_yearly) { + if (empty($by_day[$info['weekday.yearly']]) && + empty($by_day[$info['-weekday.yearly']])) { + continue; + } + } else if ($is_monthly) { + if (empty($by_day[$info['weekday.monthly']]) && + empty($by_day[$info['-weekday.monthly']])) { + continue; + } + } else { + continue; + } + } + } + + if ($by_monthday) { + if (empty($by_monthday[$info['monthday']]) && + empty($by_monthday[$info['-monthday']])) { + continue; + } + } + + if ($by_yearday) { + if (empty($by_yearday[$info['yearday']]) && + empty($by_yearday[$info['-yearday']])) { + continue; + } + } + + if ($by_weekno) { + if (empty($by_weekno[$info['week']]) && + empty($by_weekno[$info['-week']])) { + continue; + } + } + + if ($by_month) { + if (empty($by_month[$info['month']])) { + continue; + } + } + + $weeks[$info['week']][] = $info; + } + + return array_values($weeks); + } + + private function newMonthsSet($interval, $set) { + // NOTE: This value is also a real constant! Wow! + $months_in_year = 12; + + if ($this->cursorMonth > $months_in_year) { + $this->cursorMonth -= $months_in_year; + return array(); + } + + list($cursor, $result) = $this->newIteratorSet( + $this->cursorMonth, + $interval, + $set, + $months_in_year + 1); + + $this->cursorMonth = ($cursor - $months_in_year); + + return $result; + } + + public static function getYearMap($year, $week_start) { + static $maps = array(); + + $key = "{$year}/{$week_start}"; + if (isset($maps[$key])) { + return $maps[$key]; + } + + $map = self::newYearMap($year, $week_start); + $maps[$key] = $map; + + return $maps[$key]; + } + + private static function newYearMap($year, $weekday_start) { + $weekday_index = self::getWeekdayIndex($weekday_start); + + $is_leap = (($year % 4 === 0) && ($year % 100 !== 0)) || + ($year % 400 === 0); + + // There may be some clever way to figure out which day of the week a given + // year starts on and avoid the cost of a DateTime construction, but I + // wasn't able to turn it up and we only need to do this once per year. + $datetime = new DateTime("{$year}-01-01", new DateTimeZone('UTC')); + $weekday = (int)$datetime->format('w'); + + if ($is_leap) { + $max_day = 366; + } else { + $max_day = 365; + } + + $month_days = array( + 1 => 31, + 2 => $is_leap ? 29 : 28, + 3 => 31, + 4 => 30, + 5 => 31, + 6 => 30, + 7 => 31, + 8 => 31, + 9 => 30, + 10 => 31, + 11 => 30, + 12 => 31, + ); + + // Per the spec, the first week of the year must contain at least four + // days. If the week starts on a Monday but the year starts on a Saturday, + // the first couple of days don't count as a week. In this case, the first + // week will begin on January 3. + $first_week_size = 0; + $first_weekday = $weekday; + for ($year_day = 1; $year_day <= $max_day; $year_day++) { + $first_weekday = ($first_weekday + 1) % 7; + $first_week_size++; + if ($first_weekday === $weekday_index) { + break; + } + } + + if ($first_week_size >= 4) { + $week_number = 1; + } else { + $week_number = 0; + } + + $info_map = array(); + + $weekday_map = self::getWeekdayIndexMap(); + $weekday_map = array_flip($weekday_map); + + $yearly_counts = array(); + $monthly_counts = array(); + + $month_number = 1; + $month_day = 1; + for ($year_day = 1; $year_day <= $max_day; $year_day++) { + $key = "{$month_number}M{$month_day}D"; + + $short_day = $weekday_map[$weekday]; + if (empty($yearly_counts[$short_day])) { + $yearly_counts[$short_day] = 0; + } + $yearly_counts[$short_day]++; + + if (empty($monthly_counts[$month_number][$short_day])) { + $monthly_counts[$month_number][$short_day] = 0; + } + $monthly_counts[$month_number][$short_day]++; + + $info = array( + 'year' => $year, + 'key' => $key, + 'month' => $month_number, + 'monthday' => $month_day, + '-monthday' => -$month_days[$month_number] + $month_day - 1, + 'yearday' => $year_day, + '-yearday' => -$max_day + $year_day - 1, + 'week' => $week_number, + 'weekday' => $short_day, + 'weekday.yearly' => $yearly_counts[$short_day], + 'weekday.monthly' => $monthly_counts[$month_number][$short_day], + ); + + $info_map[$key] = $info; + + $weekday = ($weekday + 1) % 7; + if ($weekday === $weekday_index) { + $week_number++; + } + + $month_day = ($month_day + 1); + if ($month_day > $month_days[$month_number]) { + $month_day = 1; + $month_number++; + } + } + + // Check how long the final week is. If it doesn't have four days, this + // is really the first week of the next year. + $final_week = array(); + foreach ($info_map as $key => $info) { + if ($info['week'] == $week_number) { + $final_week[] = $key; + } + } + + if (count($final_week) < 4) { + $week_number = $week_number - 1; + $next_year = self::getYearMap($year + 1, $weekday_start); + $next_year_weeks = $next_year['weekCount']; + } else { + $next_year_weeks = null; + } + + if ($first_week_size < 4) { + $last_year = self::getYearMap($year - 1, $weekday_start); + $last_year_weeks = $last_year['weekCount']; + } else { + $last_year_weeks = null; + } + + // Now that we know how many weeks the year has, we can compute the + // negative offsets. + foreach ($info_map as $key => $info) { + $week = $info['week']; + + if ($week === 0) { + // If this day is part of the first partial week of the year, give + // it the week number of the last week of the prior year instead. + $info['week'] = $last_year_weeks; + $info['-week'] = -1; + } else if ($week > $week_number) { + // If this day is part of the last partial week of the year, give + // it week numbers from the next year. + $info['week'] = 1; + $info['-week'] = -$next_year_weeks; + } else { + $info['-week'] = -$week_number + $week - 1; + } + + // Do all the arithmetic to figure out if this is the -19th Thursday + // in the year and such. + $month_number = $info['month']; + $short_day = $info['weekday']; + $monthly_count = $monthly_counts[$month_number][$short_day]; + $monthly_index = $info['weekday.monthly']; + $info['-weekday.monthly'] = -$monthly_count + $monthly_index - 1; + $info['-weekday.monthly'] .= $short_day; + $info['weekday.monthly'] .= $short_day; + + $yearly_count = $yearly_counts[$short_day]; + $yearly_index = $info['weekday.yearly']; + $info['-weekday.yearly'] = -$yearly_count + $yearly_index - 1; + $info['-weekday.yearly'] .= $short_day; + $info['weekday.yearly'] .= $short_day; + + $info_map[$key] = $info; + } + + $week_map = array(); + foreach ($info_map as $key => $info) { + $week_map[$info['week']][] = $key; + } + + return array( + 'info' => $info_map, + 'weekCount' => $week_number, + 'dayCount' => $max_day, + 'monthDays' => $month_days, + 'weekMap' => $week_map, + ); } + private function newIteratorSet($cursor, $interval, $set, $limit) { + if ($interval < 1) { + throw new Exception( + pht( + 'Invalid iteration interval ("%d"), must be at least 1.', + $interval)); + } + + $result = array(); + $seen = array(); + + $ii = $cursor; + while (true) { + if (!$set || isset($set[$ii])) { + $result[] = $ii; + } + + $ii = ($ii + $interval); + + if ($ii >= $limit) { + break; + } + } + + sort($result); + $result = array_values($result); + + return array($ii, $result); + } + + private function applySetPos(array $values, array $setpos) { + $select = array(); + + $count = count($values); + foreach ($setpos as $pos) { + if ($pos > 0 && $pos <= $count) { + $select[] = ($pos - 1); + } else if ($pos < 0 && $pos >= -$count) { + $select[] = ($count + $pos); + } + } + + sort($select); + $select = array_unique($select); + + return array_select_keys($values, $select); + } + + private function assertByRange( + $source, + array $values, + $min, + $max, + $allow_zero = true) { + + foreach ($values as $value) { + if (!is_int($value)) { + throw new Exception( + pht( + 'Value "%s" in RRULE "%s" parameter is invalid: values must be '. + 'integers.', + $value, + $source)); + } + + if ($value < $min || $value > $max) { + throw new Exception( + pht( + 'Value "%s" in RRULE "%s" parameter is invalid: it must be '. + 'between %s and %s.', + $value, + $source, + $min, + $max)); + } + + if (!$value && !$allow_zero) { + throw new Exception( + pht( + 'Value "%s" in RRULE "%s" parameter is invalid: it must not '. + 'be zero.', + $value, + $source)); + } + } + } + + private function getSetPositionState() { + $scale = $this->getFrequencyScale(); + + $parts = array(); + $parts[] = $this->stateYear; + + if ($scale == self::SCALE_WEEKLY) { + $parts[] = $this->stateWeek; + } else { + if ($scale < self::SCALE_YEARLY) { + $parts[] = $this->stateMonth; + } + if ($scale < self::SCALE_MONTHLY) { + $parts[] = $this->stateDay; + } + if ($scale < self::SCALE_DAILY) { + $parts[] = $this->stateHour; + } + if ($scale < self::SCALE_HOURLY) { + $parts[] = $this->stateMinute; + } + } + + return implode('/', $parts); + } + + private function rewindMonth() { + while ($this->cursorMonth < 1) { + $this->cursorYear--; + $this->cursorMonth += 12; + } + } + + private function rewindWeek() { + $week_start = $this->getWeekStart(); + while ($this->cursorWeek < 1) { + $this->cursorYear--; + $year_map = $this->getYearMap($this->cursorYear, $week_start); + $this->cursorWeek += $year_map['weekCount']; + } + } + + private function rewindDay() { + $week_start = $this->getWeekStart(); + while ($this->cursorDay < 1) { + $year_map = $this->getYearMap($this->cursorYear, $week_start); + $this->cursorDay += $year_map['monthDays'][$this->cursorMonth]; + $this->cursorMonth--; + $this->rewindMonth(); + } + } + + private function rewindHour() { + while ($this->cursorHour < 0) { + $this->cursorHour += 24; + $this->cursorDay--; + $this->rewindDay(); + } + } + + private function rewindMinute() { + while ($this->cursorMinute < 0) { + $this->cursorMinute += 60; + $this->cursorHour--; + $this->rewindHour(); + } + } + + private function advanceCursorState( + array $cursor, + $scale, + $interval, + $week_start) { + + $state = array( + 'year' => $this->stateYear, + 'month' => $this->stateMonth, + 'week' => $this->stateWeek, + 'day' => $this->stateDay, + 'hour' => $this->stateHour, + ); + + // In the common case when the interval is 1, we'll visit every possible + // value so we don't need to do any math and can just jump to the first + // hour, day, etc. + if ($interval == 1) { + if ($this->isCursorBehind($cursor, $state, $scale)) { + switch ($scale) { + case self::SCALE_DAILY: + $this->cursorDay = 1; + break; + case self::SCALE_HOURLY: + $this->cursorHour = 0; + break; + case self::SCALE_WEEKLY: + $this->cursorWeek = 1; + break; + } + } + + return array(false, $state); + } + + $year_map = $this->getYearMap($cursor['year'], $week_start); + while ($this->isCursorBehind($cursor, $state, $scale)) { + switch ($scale) { + case self::SCALE_DAILY: + $cursor['day'] += $interval; + break; + case self::SCALE_HOURLY: + $cursor['hour'] += $interval; + break; + case self::SCALE_WEEKLY: + $cursor['week'] += $interval; + break; + } + + if ($scale <= self::SCALE_HOURLY) { + while ($cursor['hour'] >= 24) { + $cursor['hour'] -= 24; + $cursor['day']++; + } + } + + if ($scale == self::SCALE_WEEKLY) { + while ($cursor['week'] > $year_map['weekCount']) { + $cursor['week'] -= $year_map['weekCount']; + $cursor['year']++; + $year_map = $this->getYearMap($cursor['year'], $week_start); + } + } + + if ($scale <= self::SCALE_DAILY) { + while ($cursor['day'] > $year_map['monthDays'][$cursor['month']]) { + $cursor['day'] -= $year_map['monthDays'][$cursor['month']]; + $cursor['month']++; + if ($cursor['month'] > 12) { + $cursor['month'] -= 12; + $cursor['year']++; + $year_map = $this->getYearMap($cursor['year'], $week_start); + } + } + } + } + + switch ($scale) { + case self::SCALE_DAILY: + $this->cursorDay = $cursor['day']; + break; + case self::SCALE_HOURLY: + $this->cursorHour = $cursor['hour']; + break; + case self::SCALE_WEEKLY: + $this->cursorWeek = $cursor['week']; + break; + } + + $skip = $this->isCursorBehind($state, $cursor, $scale); + + return array($skip, $cursor); + } + + private function isCursorBehind(array $cursor, array $state, $scale) { + if ($cursor['year'] < $state['year']) { + return true; + } else if ($cursor['year'] > $state['year']) { + return false; + } + + if ($scale == self::SCALE_WEEKLY) { + return false; + } + + if ($cursor['month'] < $state['month']) { + return true; + } else if ($cursor['month'] > $state['month']) { + return false; + } + + if ($scale >= self::SCALE_DAILY) { + return false; + } + + if ($cursor['day'] < $state['day']) { + return true; + } else if ($cursor['day'] > $state['day']) { + return false; + } + + if ($scale >= self::SCALE_HOURLY) { + return false; + } + + if ($cursor['hour'] < $state['hour']) { + return true; + } else if ($cursor['hour'] > $state['hour']) { + return false; + } + + return false; + } + + } diff --git a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php new file mode 100644 index 00000000..228a921b --- /dev/null +++ b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php @@ -0,0 +1,1750 @@ +setStartDateTime($start) + ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_DAILY); + + $set = id(new PhutilCalendarRecurrenceSet()) + ->addSource($rrule); + + $result = $set->getEventsBetween(null, null, 3); + + $expect = array( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'), + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), + ); + + $this->assertEqual( + mpull($expect, 'getISO8601'), + mpull($result, 'getISO8601'), + pht('Simple daily event.')); + + + + $rrule = id(new PhutilCalendarRecurrenceRule()) + ->setStartDateTime($start) + ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_HOURLY) + ->setByHour(array(12, 13)); + + $set = id(new PhutilCalendarRecurrenceSet()) + ->addSource($rrule); + + $result = $set->getEventsBetween(null, null, 5); + + $expect = array( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T130000Z'), + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'), + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T130000Z'), + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'), + ); + + $this->assertEqual( + mpull($expect, 'getISO8601'), + mpull($result, 'getISO8601'), + pht('Hourly event with BYHOUR.')); + + + $rrule = id(new PhutilCalendarRecurrenceRule()) + ->setStartDateTime($start) + ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY); + + $set = id(new PhutilCalendarRecurrenceSet()) + ->addSource($rrule); + + $result = $set->getEventsBetween(null, null, 2); + + $expect = array( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), + PhutilCalendarAbsoluteDateTime::newFromISO8601('20170101T120000Z'), + ); + + $this->assertEqual( + mpull($expect, 'getISO8601'), + mpull($result, 'getISO8601'), + pht('Yearly event.')); + + + // This is an efficiency test for bizarre rules: it defines a secondly + // event which only occurs one a year, and generates 3 instances of it. + // This implementation should be fast enough that this test doesn't take + // a significant amount of time. + + $rrule = id(new PhutilCalendarRecurrenceRule()) + ->setStartDateTime($start) + ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_SECONDLY) + ->setByMonth(array(1)) + ->setByMonthDay(array(1)) + ->setByHour(array(12)) + ->setByMinute(array(0)) + ->setBySecond(array(0)); + + $set = id(new PhutilCalendarRecurrenceSet()) + ->addSource($rrule); + + $result = $set->getEventsBetween(null, null, 3); + + $expect = array( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'), + PhutilCalendarAbsoluteDateTime::newFromISO8601('20170101T120000Z'), + PhutilCalendarAbsoluteDateTime::newFromISO8601('20180101T120000Z'), + ); + + $this->assertEqual( + mpull($expect, 'getISO8601'), + mpull($result, 'getISO8601'), + pht('Secondly event with many constraints.')); + } + + public function testYearlyRecurrenceRules() { + $tests = array(); + $expect = array(); + + $tests[] = array(); + $expect[] = array( + '19970902', + '19980902', + '19990902', + ); + + $tests[] = array( + 'INTERVAL' => 2, + ); + $expect[] = array( + '19970902', + '19990902', + '20010902', + ); + + $tests[] = array( + 'DTSTART' => '20000229', + ); + $expect[] = array( + '20000229', + '20040229', + '20080229', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + ); + $expect[] = array( + '19980102', + '19980302', + '19990102', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(1, 3), + ); + $expect[] = array( + '19970903', + '19971001', + '19971003', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + 'BYMONTHDAY' => array(5, 7), + ); + $expect[] = array( + '19980105', + '19980107', + '19980305', + ); + + $tests[] = array( + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19970902', + '19970904', + '19970909', + ); + + $tests[] = array( + 'BYDAY' => array('SU'), + ); + $expect[] = array( + '19970907', + '19970914', + '19970921', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19980101', + '19980106', + '19980108', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(1, 3), + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19980101', + '19980203', + '19980303', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(1, 3), + 'BYDAY' => array('TU', 'TH'), + 'BYMONTH' => array(1, 3), + ); + $expect[] = array( + '19980101', + '19980303', + '20010301', + ); + + $tests[] = array( + 'BYDAY' => array('1TU', '-1TH'), + ); + $expect[] = array( + '19971225', + '19980106', + '19981231', + ); + + // Same test as above, just making sure the optional "+" syntax works. + $tests[] = array( + 'BYDAY' => array('+1TU', '-1TH'), + ); + $expect[] = array( + '19971225', + '19980106', + '19981231', + ); + + $tests[] = array( + 'BYDAY' => array('3TU', '-3TH'), + ); + $expect[] = array( + '19971211', + '19980120', + '19981217', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + 'BYDAY' => array('1TU', '-1TH'), + ); + $expect[] = array( + '19980106', + '19980129', + '19980303', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + 'BYDAY' => array('3TU', '-3TH'), + ); + $expect[] = array( + '19980115', + '19980120', + '19980312', + ); + + $tests[] = array( + 'BYYEARDAY' => array(1, 100, 200, 365), + 'COUNT' => 4, + ); + $expect[] = array( + '19971231', + '19980101', + '19980410', + '19980719', + ); + + $tests[] = array( + 'BYYEARDAY' => array(-365, -266, -166, -1), + 'COUNT' => 4, + ); + $expect[] = array( + '19971231', + '19980101', + '19980410', + '19980719', + ); + + $tests[] = array( + 'BYYEARDAY' => array(1, 100, 200, 365), + 'BYMONTH' => array(4, 7), + 'COUNT' => 4, + ); + $expect[] = array( + '19980410', + '19980719', + '19990410', + '19990719', + ); + + $tests[] = array( + 'BYYEARDAY' => array(-365, -266, -166, -1), + 'BYMONTH' => array(4, 7), + 'COUNT' => 4, + ); + $expect[] = array( + '19980410', + '19980719', + '19990410', + '19990719', + ); + + $tests[] = array( + 'BYWEEKNO' => array(20), + ); + $expect[] = array( + '19980511', + '19980512', + '19980513', + ); + + $tests[] = array( + 'BYWEEKNO' => array(1), + 'BYDAY' => array('MO'), + ); + $expect[] = array( + '19971229', + '19990104', + '20000103', + ); + + $tests[] = array( + 'BYWEEKNO' => array(52), + 'BYDAY' => array('SU'), + ); + $expect[] = array( + '19971228', + '19981227', + '20000102', + ); + + $tests[] = array( + 'BYWEEKNO' => array(-1), + 'BYDAY' => array('SU'), + ); + $expect[] = array( + '19971228', + '19990103', + '20000102', + ); + + $tests[] = array( + 'BYWEEKNO' => array(53), + 'BYDAY' => array('MO'), + ); + $expect[] = array( + '19981228', + '20041227', + '20091228', + ); + + $tests[] = array( + 'BYHOUR' => array(6, 18), + ); + $expect[] = array( + '19970902T060000Z', + '19970902T180000Z', + '19980902T060000Z', + ); + + $tests[] = array( + 'BYMINUTE' => array(15, 30), + ); + $expect[] = array( + '19970902T001500Z', + '19970902T003000Z', + '19980902T001500Z', + ); + + $tests[] = array( + 'BYSECOND' => array(10, 20), + ); + $expect[] = array( + '19970902T000010Z', + '19970902T000020Z', + '19980902T000010Z', + ); + + $tests[] = array( + 'BYHOUR' => array(6, 18), + 'BYMINUTE' => array(15, 30), + ); + $expect[] = array( + '19970902T061500Z', + '19970902T063000Z', + '19970902T181500Z', + ); + + $tests[] = array( + 'BYHOUR' => array(6, 18), + 'BYSECOND' => array(10, 20), + ); + $expect[] = array( + '19970902T060010Z', + '19970902T060020Z', + '19970902T180010Z', + ); + + $tests[] = array( + 'BYMINUTE' => array(15, 30), + 'BYSECOND' => array(10, 20), + ); + $expect[] = array( + '19970902T001510Z', + '19970902T001520Z', + '19970902T003010Z', + ); + + $tests[] = array( + 'BYHOUR' => array(6, 18), + 'BYMINUTE' => array(15, 30), + 'BYSECOND' => array(10, 20), + ); + $expect[] = array( + '19970902T061510Z', + '19970902T061520Z', + '19970902T063010Z', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(15), + 'BYHOUR' => array(6, 18), + 'BYSETPOS' => array(3, -3), + ); + $expect[] = array( + '19971115T180000Z', + '19980215T060000Z', + '19981115T180000Z', + ); + + $this->assertRules( + array( + 'FREQ' => 'YEARLY', + 'COUNT' => 3, + 'DTSTART' => '19970902', + ), + $tests, + $expect); + } + + public function testMonthlyRecurrenceRules() { + $tests = array(); + $expect = array(); + + $tests[] = array(); + $expect[] = array( + '19970902', + '19971002', + '19971102', + ); + + $tests[] = array( + 'INTERVAL' => 2, + ); + $expect[] = array( + '19970902', + '19971102', + '19980102', + ); + + $tests[] = array( + 'INTERVAL' => 18, + ); + $expect[] = array( + '19970902', + '19990302', + '20000902', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + ); + $expect[] = array( + '19980102', + '19980302', + '19990102', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(1, 3), + ); + $expect[] = array( + '19970903', + '19971001', + '19971003', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(5, 7), + 'BYMONTH' => array(1, 3), + ); + $expect[] = array( + '19980105', + '19980107', + '19980305', + ); + + $tests[] = array( + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19970902', + '19970904', + '19970909', + ); + + $tests[] = array( + 'BYDAY' => array('3MO'), + ); + $expect[] = array( + '19970915', + '19971020', + '19971117', + ); + + $tests[] = array( + 'BYDAY' => array('1TU', '-1TH'), + ); + $expect[] = array( + '19970902', + '19970925', + '19971007', + ); + + $tests[] = array( + 'BYDAY' => array('3TU', '-3TH'), + ); + $expect[] = array( + '19970911', + '19970916', + '19971016', + ); + + $tests[] = array( + 'BYDAY' => array('TU', 'TH'), + 'BYMONTH' => array(1, 3), + ); + $expect[] = array( + '19980101', + '19980106', + '19980108', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + 'BYDAY' => array('1TU', '-1TH'), + ); + $expect[] = array( + '19980106', + '19980129', + '19980303', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + 'BYDAY' => array('3TU', '-3TH'), + ); + $expect[] = array( + '19980115', + '19980120', + '19980312', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(1, 3), + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19980101', + '19980203', + '19980303', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + 'BYMONTHDAY' => array(1, 3), + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19980101', + '19980303', + '20010301', + ); + + $tests[] = array( + 'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR'), + 'BYSETPOS' => array(-1), + ); + $expect[] = array( + '19970930', + '19971031', + '19971128', + ); + + $tests[] = array( + 'BYDAY' => array('1MO', '1TU', '1WE', '1TH', '1FR', '-1FR'), + 'BYMONTHDAY' => array(1, -1, -2), + ); + $expect[] = array( + '19971001', + '19971031', + '19971201', + ); + + $tests[] = array( + 'BYDAY' => array('1MO', '1TU', '1WE', '1TH', 'FR'), + 'BYMONTHDAY' => array(1, -1, -2), + ); + $expect[] = array( + '19971001', + '19971031', + '19971201', + ); + + $tests[] = array( + 'BYHOUR' => array(6, 18), + ); + $expect[] = array( + '19970902T060000Z', + '19970902T180000Z', + '19971002T060000Z', + ); + + $tests[] = array( + 'BYMINUTE' => array(6, 18), + ); + $expect[] = array( + '19970902T000600Z', + '19970902T001800Z', + '19971002T000600Z', + ); + + $tests[] = array( + 'BYSECOND' => array(6, 18), + ); + $expect[] = array( + '19970902T000006Z', + '19970902T000018Z', + '19971002T000006Z', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(13, 17), + 'BYHOUR' => array(6, 18), + 'BYSETPOS' => array(3, -3), + ); + $expect[] = array( + '19970913T180000Z', + '19970917T060000Z', + '19971013T180000Z', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(13, 17), + 'BYHOUR' => array(6, 18), + 'BYSETPOS' => array(3, 3, -3), + ); + $expect[] = array( + '19970913T180000Z', + '19970917T060000Z', + '19971013T180000Z', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(13, 17), + 'BYHOUR' => array(6, 18), + 'BYSETPOS' => array(4, -1), + ); + $expect[] = array( + '19970917T180000Z', + '19971017T180000Z', + '19971117T180000Z', + ); + + $this->assertRules( + array( + 'FREQ' => 'MONTHLY', + 'COUNT' => 3, + 'DTSTART' => '19970902', + ), + $tests, + $expect); + } + + public function testWeeklyRecurrenceRules() { + $tests = array(); + $expect = array(); + + $tests[] = array(); + $expect[] = array( + '19970902', + '19970909', + '19970916', + ); + + $tests[] = array( + 'INTERVAL' => 2, + ); + $expect[] = array( + '19970902', + '19970916', + '19970930', + ); + + $tests[] = array( + 'INTERVAL' => 20, + ); + $expect[] = array( + '19970902', + '19980120', + '19980609', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + ); + $expect[] = array( + '19980106', + '19980113', + '19980120', + ); + + $tests[] = array( + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19970902', + '19970904', + '19970909', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19980101', + '19980106', + '19980108', + ); + + $tests[] = array( + 'BYHOUR' => array(6, 18), + ); + $expect[] = array( + '19970902T060000Z', + '19970902T180000Z', + '19970909T060000Z', + ); + + $tests[] = array( + 'BYDAY' => array('TU', 'TH'), + 'BYHOUR' => array(6, 18), + 'BYSETPOS' => array(3, -3), + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970902T180000Z', + '19970904T060000Z', + '19970909T180000Z', + ); + + $this->assertRules( + array( + 'FREQ' => 'WEEKLY', + 'COUNT' => 3, + 'DTSTART' => '19970902', + ), + $tests, + $expect); + } + + public function testDailyRecurrenceRules() { + $tests = array(); + $expect = array(); + + $tests[] = array(); + $expect[] = array( + '19970902', + '19970903', + '19970904', + ); + + $tests[] = array( + 'INTERVAL' => 2, + ); + $expect[] = array( + '19970902', + '19970904', + '19970906', + ); + + $tests[] = array( + 'INTERVAL' => 92, + ); + $expect[] = array( + '19970902', + '19971203', + '19980305', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + ); + $expect[] = array( + '19980101', + '19980102', + '19980103', + ); + + // This is testing that INTERVAL is respected in the presence of a BYMONTH + // filter which skips some months. + $tests[] = array( + 'BYMONTH' => array(12), + 'INTERVAL' => 17, + ); + $expect[] = array( + '19971213', + '19971230', + '19981205', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(1, 3), + ); + $expect[] = array( + '19970903', + '19971001', + '19971003', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + 'BYMONTHDAY' => array(5, 7), + ); + $expect[] = array( + '19980105', + '19980107', + '19980305', + ); + + $tests[] = array( + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19970902', + '19970904', + '19970909', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19980101', + '19980106', + '19980108', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(1, 3), + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19980101', + '19980203', + '19980303', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + 'BYMONTHDAY' => array(1, 3), + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19980101', + '19980303', + '20010301', + ); + + $tests[] = array( + 'BYHOUR' => array(6, 18), + 'BYMINUTE' => array(15, 45), + 'BYSETPOS' => array(3, -3), + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970902T181500Z', + '19970903T064500Z', + '19970903T181500Z', + ); + + $this->assertRules( + array( + 'FREQ' => 'DAILY', + 'COUNT' => 3, + 'DTSTART' => '19970902', + ), + $tests, + $expect); + } + + public function testHourlyRecurrenceRules() { + $tests = array(); + $expect = array(); + + $tests[] = array(); + $expect[] = array( + '19970902T090000Z', + '19970902T100000Z', + '19970902T110000Z', + ); + + $tests[] = array( + 'INTERVAL' => 2, + ); + $expect[] = array( + '19970902T090000Z', + '19970902T110000Z', + '19970902T130000Z', + ); + + $tests[] = array( + 'INTERVAL' => 769, + ); + $expect[] = array( + '19970902T090000Z', + '19971004T100000Z', + '19971105T110000Z', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + ); + $expect[] = array( + '19980101T000000Z', + '19980101T010000Z', + '19980101T020000Z', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(1, 3), + ); + $expect[] = array( + '19970903T000000Z', + '19970903T010000Z', + '19970903T020000Z', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + 'BYMONTHDAY' => array(5, 7), + ); + $expect[] = array( + '19980105T000000Z', + '19980105T010000Z', + '19980105T020000Z', + ); + + $tests[] = array( + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19970902T090000Z', + '19970902T100000Z', + '19970902T110000Z', + ); + + $tests[] = array( + 'BYMONTH' => array(1, 3), + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19980101T000000Z', + '19980101T010000Z', + '19980101T020000Z', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(1, 3), + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19980101T000000Z', + '19980101T010000Z', + '19980101T020000Z', + ); + + $tests[] = array( + 'BYMONTHDAY' => array(1, 3), + 'BYMONTH' => array(1, 3), + 'BYDAY' => array('TU', 'TH'), + ); + $expect[] = array( + '19980101T000000Z', + '19980101T010000Z', + '19980101T020000Z', + ); + + $tests[] = array( + 'COUNT' => 4, + 'BYYEARDAY' => array(1, 100, 200, 365), + ); + $expect[] = array( + '19971231T000000Z', + '19971231T010000Z', + '19971231T020000Z', + '19971231T030000Z', + ); + + $tests[] = array( + 'COUNT' => 4, + 'BYYEARDAY' => array(-365, -266, -166, -1), + ); + $expect[] = array( + '19971231T000000Z', + '19971231T010000Z', + '19971231T020000Z', + '19971231T030000Z', + ); + + $tests[] = array( + 'COUNT' => 4, + 'BYMONTH' => array(4, 7), + 'BYYEARDAY' => array(1, 100, 200, 365), + ); + $expect[] = array( + '19980410T000000Z', + '19980410T010000Z', + '19980410T020000Z', + '19980410T030000Z', + ); + + $tests[] = array( + 'COUNT' => 4, + 'BYMONTH' => array(4, 7), + 'BYYEARDAY' => array(-365, -266, -166, -1), + ); + $expect[] = array( + '19980410T000000Z', + '19980410T010000Z', + '19980410T020000Z', + '19980410T030000Z', + ); + + $tests[] = array( + 'BYHOUR' => array(6, 18), + ); + $expect[] = array( + '19970902T180000Z', + '19970903T060000Z', + '19970903T180000Z', + ); + + $tests[] = array( + 'BYMINUTE' => array(15, 45), + 'BYSECOND' => array(15, 45), + 'BYSETPOS' => array(3, -3), + ); + $expect[] = array( + '19970902T091545Z', + '19970902T094515Z', + '19970902T101545Z', + ); + + $this->assertRules( + array( + 'FREQ' => 'HOURLY', + 'COUNT' => 3, + 'DTSTART' => '19970902T090000Z', + ), + $tests, + $expect); + } + + public function testMinutelyRecurrenceRules() { + $tests = array(); + $expect = array(); + + $tests[] = array( + ); + $expect[] = array( + '19970902T090000Z', + '19970902T090100Z', + '19970902T090200Z', + ); + + $tests[] = array( + 'INTERVAL' => 2, + ); + $expect[] = array( + '19970902T090000Z', + '19970902T090200Z', + '19970902T090400Z', + ); + + $tests[] = array( + 'BYHOUR' => array(6, 18), + 'BYMINUTE' => array(6, 18), + 'BYSECOND' => array(6, 18), + ); + $expect[] = array( + '19970902T180606Z', + '19970902T180618Z', + '19970902T181806Z', + ); + + $tests[] = array( + 'BYSECOND' => array(15, 30, 45), + 'BYSETPOS' => array(3, -3), + ); + $expect[] = array( + '19970902T090015Z', + '19970902T090045Z', + '19970902T090115Z', + ); + + $this->assertRules( + array( + 'FREQ' => 'MINUTELY', + 'COUNT' => 3, + 'DTSTART' => '19970902T090000Z', + ), + $tests, + $expect); + } + + public function testSecondlyRecurrenceRules() { + $tests = array(); + $expect = array(); + + $tests[] = array(); + $expect[] = array( + '19970902T090000Z', + '19970902T090001Z', + '19970902T090002Z', + ); + + $tests[] = array( + 'INTERVAL' => 2, + ); + $expect[] = array( + '19970902T090000Z', + '19970902T090002Z', + '19970902T090004Z', + ); + + $tests[] = array( + 'INTERVAL' => 90061, + ); + $expect[] = array( + '19970902T090000Z', + '19970903T100101Z', + '19970904T110202Z', + ); + + $tests[] = array( + 'BYSECOND' => array(0), + 'BYMINUTE' => array(1), + 'DTSTART' => '20100322T120100Z', + ); + $expect[] = array( + '20100322T120100Z', + '20100322T130100Z', + '20100322T140100Z', + ); + + $this->assertRules( + array( + 'FREQ' => 'SECONDLY', + 'COUNT' => 3, + 'DTSTART' => '19970902T090000Z', + ), + $tests, + $expect); + } + + public function testRFC5545RecurrenceRules() { + // These tests are derived from the examples in RFC5545. + $tests = array(); + $expect = array(); + + $tests[] = array( + 'FREQ' => 'DAILY', + 'COUNT' => 10, + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970902T090000Z', + '19970903T090000Z', + '19970904T090000Z', + '19970905T090000Z', + '19970906T090000Z', + '19970907T090000Z', + '19970908T090000Z', + '19970909T090000Z', + '19970910T090000Z', + '19970911T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'DAILY', + 'INTERVAL' => 2, + 'DTSTART' => '19970902T090000Z', + 'COUNT' => 5, + ); + $expect[] = array( + '19970902T090000Z', + '19970904T090000Z', + '19970906T090000Z', + '19970908T090000Z', + '19970910T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'YEARLY', + 'BYMONTH' => array(1), + 'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'), + 'DTSTART' => '19970902T090000Z', + 'COUNT' => 3, + ); + $expect[] = array( + '19980101T090000Z', + '19980102T090000Z', + '19980103T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'MONTHLY', + 'COUNT' => 3, + 'BYDAY' => array('1FR'), + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970905T090000Z', + '19971003T090000Z', + '19971107T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'MONTHLY', + 'INTERVAL' => 2, + 'COUNT' => 5, + 'BYDAY' => array('1SU', '-1SU'), + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970907T090000Z', + '19970928T090000Z', + '19971102T090000Z', + '19971130T090000Z', + '19980104T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'MONTHLY', + 'COUNT' => 6, + 'BYDAY' => array('-2MO'), + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970922T090000Z', + '19971020T090000Z', + '19971117T090000Z', + '19971222T090000Z', + '19980119T090000Z', + '19980216T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'MONTHLY', + 'COUNT' => 6, + 'BYMONTHDAY' => array(-3), + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970928T090000Z', + '19971029T090000Z', + '19971128T090000Z', + '19971229T090000Z', + '19980129T090000Z', + '19980226T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'MONTHLY', + 'COUNT' => 5, + 'BYMONTHDAY' => array(2, 15), + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970902T090000Z', + '19970915T090000Z', + '19971002T090000Z', + '19971015T090000Z', + '19971102T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'MONTHLY', + 'COUNT' => 5, + 'BYMONTHDAY' => array(-1, 1), + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970930T090000Z', + '19971001T090000Z', + '19971031T090000Z', + '19971101T090000Z', + '19971130T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'MONTHLY', + 'COUNT' => 7, + 'INTERVAL' => 18, + 'BYMONTHDAY' => array(10, 11, 12, 13, 14, 15), + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970910T090000Z', + '19970911T090000Z', + '19970912T090000Z', + '19970913T090000Z', + '19970914T090000Z', + '19970915T090000Z', + '19990310T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'MONTHLY', + 'COUNT' => 6, + 'INTERVAL' => 2, + 'BYDAY' => array('TU'), + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970902T090000Z', + '19970909T090000Z', + '19970916T090000Z', + '19970923T090000Z', + '19970930T090000Z', + '19971104T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'YEARLY', + 'COUNT' => 10, + 'BYMONTH' => array(6, 7), + 'DTSTART' => '19970610T090000Z', + ); + $expect[] = array( + '19970610T090000Z', + '19970710T090000Z', + '19980610T090000Z', + '19980710T090000Z', + '19990610T090000Z', + '19990710T090000Z', + '20000610T090000Z', + '20000710T090000Z', + '20010610T090000Z', + '20010710T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'YEARLY', + 'COUNT' => 4, + 'INTERVAL' => 3, + 'BYYEARDAY' => array(1, 100, 200), + 'DTSTART' => '19970101T090000Z', + ); + $expect[] = array( + '19970101T090000Z', + '19970410T090000Z', + '19970719T090000Z', + '20000101T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'YEARLY', + 'COUNT' => 3, + 'BYDAY' => array('20MO'), + 'DTSTART' => '19970519T090000Z', + ); + $expect[] = array( + '19970519T090000Z', + '19980518T090000Z', + '19990517T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'YEARLY', + 'COUNT' => 3, + 'BYWEEKNO' => array(20), + 'BYDAY' => array('MO'), + 'DTSTART' => '19970512T090000Z', + ); + $expect[] = array( + '19970512T090000Z', + '19980511T090000Z', + '19990517T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'YEARLY', + 'BYDAY' => array('TH'), + 'BYMONTH' => array(3), + 'DTSTART' => '19970313T090000Z', + 'COUNT' => 5, + ); + $expect[] = array( + '19970313T090000Z', + '19970320T090000Z', + '19970327T090000Z', + '19980305T090000Z', + '19980312T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'YEARLY', + 'BYDAY' => array('TH'), + 'BYMONTH' => array(6, 7, 8), + 'DTSTART' => '19970101T090000Z', + 'COUNT' => 15, + ); + $expect[] = array( + '19970605T090000Z', + '19970612T090000Z', + '19970619T090000Z', + '19970626T090000Z', + '19970703T090000Z', + '19970710T090000Z', + '19970717T090000Z', + '19970724T090000Z', + '19970731T090000Z', + '19970807T090000Z', + '19970814T090000Z', + '19970821T090000Z', + '19970828T090000Z', + '19980604T090000Z', + '19980611T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'YEARLY', + 'BYDAY' => array('FR'), + 'BYMONTHDAY' => array(13), + 'COUNT' => 4, + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19980213T090000Z', + '19980313T090000Z', + '19981113T090000Z', + '19990813T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'MONTHLY', + 'BYDAY' => array('SA'), + 'BYMONTHDAY' => array(7, 8, 9, 10, 11, 12, 13), + 'COUNT' => 10, + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970913T090000Z', + '19971011T090000Z', + '19971108T090000Z', + '19971213T090000Z', + '19980110T090000Z', + '19980207T090000Z', + '19980307T090000Z', + '19980411T090000Z', + '19980509T090000Z', + '19980613T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'YEARLY', + 'INTERVAL' => 4, + 'BYMONTH' => array(11), + 'BYDAY' => array('TU'), + 'BYMONTHDAY' => array(2, 3, 4, 5, 6, 7, 8), + 'COUNT' => 6, + 'DTSTART' => '19961105T090000Z', + ); + $expect[] = array( + '19961105T090000Z', + '20001107T090000Z', + '20041102T090000Z', + '20081104T090000Z', + '20121106T090000Z', + '20161108T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'MONTHLY', + 'BYDAY' => array('TU', 'WE', 'TH'), + 'BYSETPOS' => array(3), + 'COUNT' => 3, + 'DTSTART' => '19970904T090000Z', + ); + $expect[] = array( + '19970904T090000Z', + '19971007T090000Z', + '19971106T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'MONTHLY', + 'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR'), + 'BYSETPOS' => array(-2), + 'COUNT' => 3, + 'DTSTART' => '19970929T090000Z', + ); + $expect[] = array( + '19970929T090000Z', + '19971030T090000Z', + '19971127T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'HOURLY', + 'INTERVAL' => 3, + 'DTSTART' => '19970929T090000Z', + 'COUNT' => 3, + ); + $expect[] = array( + '19970929T090000Z', + '19970929T120000Z', + '19970929T150000Z', + ); + + $tests[] = array( + 'FREQ' => 'MINUTELY', + 'INTERVAL' => 15, + 'COUNT' => 6, + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970902T090000Z', + '19970902T091500Z', + '19970902T093000Z', + '19970902T094500Z', + '19970902T100000Z', + '19970902T101500Z', + ); + + $tests[] = array( + 'FREQ' => 'MINUTELY', + 'INTERVAL' => 90, + 'COUNT' => 4, + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970902T090000Z', + '19970902T103000Z', + '19970902T120000Z', + '19970902T133000Z', + ); + + $tests[] = array( + 'FREQ' => 'WEEKLY', + 'COUNT' => 10, + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970902T090000Z', + '19970909T090000Z', + '19970916T090000Z', + '19970923T090000Z', + '19970930T090000Z', + '19971007T090000Z', + '19971014T090000Z', + '19971021T090000Z', + '19971028T090000Z', + '19971104T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'WEEKLY', + 'INTERVAL' => 2, + 'COUNT' => 6, + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970902T090000Z', + '19970916T090000Z', + '19970930T090000Z', + '19971014T090000Z', + '19971028T090000Z', + '19971111T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'WEEKLY', + 'COUNT' => 10, + 'WKST' => 'SU', + 'BYDAY' => array('TU', 'TH'), + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970902T090000Z', + '19970904T090000Z', + '19970909T090000Z', + '19970911T090000Z', + '19970916T090000Z', + '19970918T090000Z', + '19970923T090000Z', + '19970925T090000Z', + '19970930T090000Z', + '19971002T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'WEEKLY', + 'INTERVAL' => 2, + 'COUNT' => 8, + 'WKST' => 'SU', + 'BYDAY' => array('TU', 'TH'), + 'DTSTART' => '19970902T090000Z', + ); + $expect[] = array( + '19970902T090000Z', + '19970904T090000Z', + '19970916T090000Z', + '19970918T090000Z', + '19970930T090000Z', + '19971002T090000Z', + '19971014T090000Z', + '19971016T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'WEEKLY', + 'INTERVAL' => 2, + 'COUNT' => 4, + 'BYDAY' => array('TU', 'SU'), + 'WKST' => 'MO', + 'DTSTART' => '19970805T090000Z', + ); + $expect[] = array( + '19970805T090000Z', + '19970810T090000Z', + '19970819T090000Z', + '19970824T090000Z', + ); + + $tests[] = array( + 'FREQ' => 'WEEKLY', + 'INTERVAL' => 2, + 'COUNT' => 4, + 'BYDAY' => array('TU', 'SU'), + 'WKST' => 'SU', + 'DTSTART' => '19970805T090000Z', + ); + $expect[] = array( + '19970805T090000Z', + '19970817T090000Z', + '19970819T090000Z', + '19970831T090000Z', + ); + + + $this->assertRules(array(), $tests, $expect); + } + + + private function assertRules(array $defaults, array $tests, array $expect) { + foreach ($tests as $key => $test) { + $options = $test + $defaults; + + $start = PhutilCalendarAbsoluteDateTime::newFromISO8601( + $options['DTSTART']); + + $rrule = id(new PhutilCalendarRecurrenceRule()) + ->setStartDateTime($start) + ->setFrequency($options['FREQ']); + + $interval = idx($options, 'INTERVAL'); + if ($interval) { + $rrule->setInterval($interval); + } + + $by_day = idx($options, 'BYDAY'); + if ($by_day) { + $rrule->setByDay($by_day); + } + + $by_month = idx($options, 'BYMONTH'); + if ($by_month) { + $rrule->setByMonth($by_month); + } + + $by_monthday = idx($options, 'BYMONTHDAY'); + if ($by_monthday) { + $rrule->setByMonthDay($by_monthday); + } + + $by_yearday = idx($options, 'BYYEARDAY'); + if ($by_yearday) { + $rrule->setByYearDay($by_yearday); + } + + $by_weekno = idx($options, 'BYWEEKNO'); + if ($by_weekno) { + $rrule->setByWeekNumber($by_weekno); + } + + $by_hour = idx($options, 'BYHOUR'); + if ($by_hour) { + $rrule->setByHour($by_hour); + } + + $by_minute = idx($options, 'BYMINUTE'); + if ($by_minute) { + $rrule->setByMinute($by_minute); + } + + $by_second = idx($options, 'BYSECOND'); + if ($by_second) { + $rrule->setBySecond($by_second); + } + + $by_setpos = idx($options, 'BYSETPOS'); + if ($by_setpos) { + $rrule->setBySetPosition($by_setpos); + } + + $week_start = idx($options, 'WKST'); + if ($week_start) { + $rrule->setWeekStart($week_start); + } + + $set = id(new PhutilCalendarRecurrenceSet()) + ->addSource($rrule); + + $result = $set->getEventsBetween(null, null, $options['COUNT']); + + $this->assertEqual( + $expect[$key], + mpull($result, 'getISO8601')); + } + } + + +}