diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index b1a2955..997a643 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1,1054 +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', + 'PhutilCalendarEventNode' => 'PhutilCalendarContainerNode', '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/parser/calendar/data/PhutilCalendarEventNode.php b/src/parser/calendar/data/PhutilCalendarEventNode.php index 1dfa2d4..11c0471 100644 --- a/src/parser/calendar/data/PhutilCalendarEventNode.php +++ b/src/parser/calendar/data/PhutilCalendarEventNode.php @@ -1,171 +1,171 @@ uid = $uid; return $this; } public function getUID() { return $this->uid; } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function setDescription($description) { $this->description = $description; return $this; } public function getDescription() { return $this->description; } public function setStartDateTime(PhutilCalendarDateTime $start) { $this->startDateTime = $start; return $this; } public function getStartDateTime() { return $this->startDateTime; } public function setEndDateTime(PhutilCalendarDateTime $end) { $this->endDateTime = $end; return $this; } public function getEndDateTime() { $end = $this->endDateTime; if ($end) { return $end; } $start = $this->getStartDateTime(); $duration = $this->getDuration(); if ($start && $duration) { return id(new PhutilCalendarRelativeDateTime()) ->setOrigin($start) ->setDuration($duration); } return null; } public function setDuration(PhutilCalendarDuration $duration) { $this->duration = $duration; return $this; } public function getDuration() { return $this->duration; } public function setCreatedDateTime(PhutilCalendarDateTime $created) { $this->createdDateTime = $created; return $this; } public function getCreatedDateTime() { return $this->createdDateTime; } public function setModifiedDateTime(PhutilCalendarDateTime $modified) { $this->modifiedDateTime = $modified; return $this; } public function getModifiedDateTime() { return $this->modifiedDateTime; } public function setOrganizer(PhutilCalendarUserNode $organizer) { $this->organizer = $organizer; return $this; } public function getOrganizer() { return $this->organizer; } public function setAttendees(array $attendees) { assert_instances_of($attendees, 'PhutilCalendarUserNode'); $this->attendees = $attendees; return $this; } public function getAttendees() { return $this->attendees; } public function addAttendee(PhutilCalendarUserNode $attendee) { $this->attendees[] = $attendee; return $this; } public function setRecurrenceRule( PhutilCalendarRecurrenceRule $recurrence_rule) { $this->recurrenceRule = $recurrence_rule; return $this; } public function getRecurrenceRule() { return $this->recurrenceRule; } public function setRecurrenceExceptions(array $recurrence_exceptions) { assert_instances_of($recurrence_exceptions, 'PhutilCalendarDateTime'); $this->recurrenceExceptions = $recurrence_exceptions; return $this; } public function getRecurrenceExceptions() { return $this->recurrenceExceptions; } public function setRecurrenceDates(array $recurrence_dates) { assert_instances_of($recurrence_dates, 'PhutilCalendarDateTime'); $this->recurrenceDates = $recurrence_dates; return $this; } public function getRecurrenceDates() { return $this->recurrenceDates; } public function setRecurrenceID($recurrence_id) { $this->recurrenceID = $recurrence_id; return $this; } public function getRecurrenceID() { return $this->recurrenceID; } } diff --git a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php index c2aae5b..504f7d8 100644 --- a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php +++ b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php @@ -1,1801 +1,1820 @@ getFrequency(); $interval = $this->getInterval(); if ($interval != 1) { $parts['INTERVAL'] = $interval; } $by_second = $this->getBySecond(); if ($by_second) { $parts['BYSECOND'] = $by_second; } $by_minute = $this->getByMinute(); if ($by_minute) { $parts['BYMINUTE'] = $by_minute; } $by_hour = $this->getByHour(); if ($by_hour) { $parts['BYHOUR'] = $by_hour; } $by_day = $this->getByDay(); if ($by_day) { $parts['BYDAY'] = $by_day; } $by_month = $this->getByMonth(); if ($by_month) { $parts['BYMONTH'] = $by_month; } $by_monthday = $this->getByMonthDay(); if ($by_monthday) { $parts['BYMONTHDAY'] = $by_monthday; } $by_yearday = $this->getByYearDay(); if ($by_yearday) { $parts['BYYEARDAY'] = $by_yearday; } $by_weekno = $this->getByWeekNumber(); if ($by_weekno) { $parts['BYWEEKNO'] = $by_weekno; } $by_setpos = $this->getBySetPosition(); if ($by_setpos) { $parts['BYSETPOS'] = $by_setpos; } $wkst = $this->getWeekStart(); if ($wkst != self::WEEKDAY_MONDAY) { $parts['WKST'] = $wkst; } $count = $this->getCount(); if ($count) { $parts['COUNT'] = $count; } $until = $this->getUntil(); if ($until) { $parts['UNTIL'] = $until->getISO8601(); } return $parts; } public static function newFromDictionary(array $dict) { static $expect; if ($expect === null) { $expect = array_fuse( array( 'FREQ', 'INTERVAL', 'BYSECOND', 'BYMINUTE', 'BYHOUR', 'BYDAY', 'BYMONTH', 'BYMONTHDAY', 'BYYEARDAY', 'BYWEEKNO', 'BYSETPOS', 'WKST', 'UNTIL', 'COUNT', )); } foreach ($dict as $key => $value) { if (empty($expect[$key])) { throw new Exception( pht( 'RRULE dictionary includes unknown key "%s". Expected keys '. 'are: %s.', $key, implode(', ', array_keys($expect)))); } } $rrule = id(new self()) ->setFrequency(idx($dict, 'FREQ')) ->setInterval(idx($dict, 'INTERVAL', 1)) ->setBySecond(idx($dict, 'BYSECOND', array())) ->setByMinute(idx($dict, 'BYMINUTE', array())) ->setByHour(idx($dict, 'BYHOUR', array())) ->setByDay(idx($dict, 'BYDAY', array())) ->setByMonth(idx($dict, 'BYMONTH', array())) ->setByMonthDay(idx($dict, 'BYMONTHDAY', array())) ->setByYearDay(idx($dict, 'BYYEARDAY', array())) ->setByWeekNumber(idx($dict, 'BYWEEKNO', array())) ->setBySetPosition(idx($dict, 'BYSETPOS', array())) ->setWeekStart(idx($dict, 'WKST', self::WEEKDAY_MONDAY)); $count = idx($dict, 'COUNT'); if ($count) { $rrule->setCount($count); } $until = idx($dict, 'UNTIL'); if ($until) { $until = PhutilCalendarAbsoluteDateTime::newFromISO8601($until); $rrule->setUntil($until); } return $rrule; } public function toRRULE() { $dict = $this->toDictionary(); $parts = array(); foreach ($dict as $key => $value) { if (is_array($value)) { $value = implode(',', $value); } $parts[] = "{$key}={$value}"; } return implode(';', $parts); } public static function newFromRRULE($rrule) { $parts = explode(';', $rrule); $dict = array(); foreach ($parts as $part) { list($key, $value) = explode('=', $part, 2); switch ($key) { case 'FREQ': case 'INTERVAL': case 'WKST': case 'COUNT': case 'UNTIL'; break; default: $value = explode(',', $value); break; } $dict[$key] = $value; } $int_lists = array_fuse( array( // NOTE: "BYDAY" is absent, and takes a list like "MO, TU, WE". 'BYSECOND', 'BYMINUTE', 'BYHOUR', 'BYMONTH', 'BYMONTHDAY', 'BYYEARDAY', 'BYWEEKNO', 'BYSETPOS', )); + $int_values = array_fuse( + array( + 'COUNT', + 'INTERVAL', + )); + foreach ($dict as $key => $value) { + if (isset($int_values[$key])) { + // None of these values may be negative. + if (!preg_match('/^\d+\z/', $value)) { + throw new Exception( + pht( + 'Unexpected value "%s" in "%s" RULE property: expected an '. + 'integer.', + $value, + $key)); + } + $dict[$key] = (int)$value; + } + if (isset($int_lists[$key])) { foreach ($value as $k => $v) { if (!preg_match('/^-?\d+\z/', $v)) { throw new Exception( pht( 'Unexpected value "%s" in "%s" RRULE property: expected '. 'only integers.', $v, $key)); } $value[$k] = (int)$v; } $dict[$key] = $value; } } return self::newFromDictionary($dict); } private static function getAllWeekdayConstants() { return array_keys(self::getWeekdayIndexMap()); } 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; } 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 getStartDateTime() { return $this->startDateTime; } public function setCount($count) { if ($count < 1) { throw new Exception( pht( 'RRULE COUNT value "%s" is invalid: count must be at least 1.', $count)); } $this->count = $count; return $this; } public function getCount() { return $this->count; } public function setUntil(PhutilCalendarDateTime $until) { $this->until = $until; return $this; } public function getUntil() { return $this->until; } 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 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(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(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(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(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(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->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(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 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) { 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()) ->setTimezone($this->getStartDateTime()->getTimezone()) ->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/ics/PhutilICSParser.php b/src/parser/calendar/ics/PhutilICSParser.php index 8e0ffec..80c466d 100644 --- a/src/parser/calendar/ics/PhutilICSParser.php +++ b/src/parser/calendar/ics/PhutilICSParser.php @@ -1,811 +1,839 @@ stack = array(); $this->node = null; $this->cursor = null; $this->warnings = array(); $lines = $this->unfoldICSLines($data); $this->lines = $lines; $root = $this->newICSNode(''); $this->stack[] = $root; $this->node = $root; foreach ($lines as $key => $line) { $this->cursor = $key; $matches = null; if (preg_match('(^BEGIN:(.*)\z)', $line, $matches)) { $this->beginParsingNode($matches[1]); } else if (preg_match('(^END:(.*)\z)', $line, $matches)) { $this->endParsingNode($matches[1]); } else { if (count($this->stack) < 2) { $this->raiseParseFailure( self::PARSE_ROOT_PROPERTY, pht( 'Found unexpected property at ICS document root.')); } $this->parseICSProperty($line); } } if (count($this->stack) > 1) { $this->raiseParseFailure( self::PARSE_MISSING_END, pht( 'Expected all "BEGIN:" sections in ICS document to have '. 'corresponding "END:" sections.')); } $this->node = null; $this->lines = null; $this->cursor = null; return $root; } private function getNode() { return $this->node; } private function unfoldICSLines($data) { $lines = phutil_split_lines($data, $retain_endings = false); $this->lines = $lines; // ICS files are wrapped at 75 characters, with overlong lines continued // on the following line with an initial space or tab. Unwrap all of the // lines in the file. // This unwrapping is specifically byte-oriented, not character oriented, // and RFC5545 anticipates that simple implementations may even split UTF8 // characters in the middle. $last = null; foreach ($lines as $idx => $line) { $this->cursor = $idx; if (!preg_match('/^[ \t]/', $line)) { $last = $idx; continue; } if ($last === null) { $this->raiseParseFailure( self::PARSE_INITIAL_UNFOLD, pht( 'First line of ICS file begins with a space or tab, but this '. 'marks a line which should be unfolded.')); } $lines[$last] = $lines[$last].substr($line, 1); unset($lines[$idx]); } return $lines; } private function beginParsingNode($type) { $node = $this->getNode(); $new_node = $this->newICSNode($type); if ($node instanceof PhutilCalendarContainerNode) { $node->appendChild($new_node); } else { $this->raiseParseFailure( self::PARSE_UNEXPECTED_CHILD, pht( 'Found unexpected node "%s" inside node "%s".', $new_node->getAttribute('ics.type'), $node->getAttribute('ics.type'))); } $this->stack[] = $new_node; $this->node = $new_node; return $this; } private function newICSNode($type) { switch ($type) { case '': $node = new PhutilCalendarRootNode(); break; case 'VCALENDAR': $node = new PhutilCalendarDocumentNode(); break; case 'VEVENT': $node = new PhutilCalendarEventNode(); break; default: $node = new PhutilCalendarRawNode(); break; } $node->setAttribute('ics.type', $type); return $node; } private function endParsingNode($type) { $node = $this->getNode(); if ($node instanceof PhutilCalendarRootNode) { $this->raiseParseFailure( self::PARSE_EXTRA_END, pht( 'Found unexpected "END" without a "BEGIN".')); } $old_type = $node->getAttribute('ics.type'); if ($old_type != $type) { $this->raiseParseFailure( self::PARSE_MISMATCHED_SECTIONS, pht( 'Found mismatched "BEGIN" ("%s") and "END" ("%s") sections.', $old_type, $type)); } array_pop($this->stack); $this->node = last($this->stack); return $this; } private function parseICSProperty($line) { $matches = null; // Properties begin with an alphanumeric name with no escaping, followed // by either a ";" (to begin a list of parameters) or a ":" (to begin // the actual field body). $ok = preg_match('(^([A-Za-z0-9-]+)([;:])(.*)\z)', $line, $matches); if (!$ok) { $this->raiseParseFailure( self::PARSE_MALFORMED_PROPERTY, pht( 'Found malformed property in ICS document.')); } $name = $matches[1]; $body = $matches[3]; $has_parameters = ($matches[2] == ';'); $parameters = array(); if ($has_parameters) { // Parameters are a sensible name, a literal "=", a pile of magic, // and then maybe a comma and another parameter. while (true) { // We're going to get the first couple of parts first. $ok = preg_match('(^([^=]+)=)', $body, $matches); if (!$ok) { $this->raiseParseFailure( self::PARSE_MALFORMED_PARAMETER_NAME, pht( 'Found malformed property in ICS document: %s', $body)); } $param_name = $matches[1]; $body = substr($body, strlen($matches[0])); // Now we're going to match zero or more values. $param_values = array(); while (true) { // The value can either be a double-quoted string or an unquoted // string, with some characters forbidden. if (strlen($body) && $body[0] == '"') { $is_quoted = true; $ok = preg_match( '(^"([^\x00-\x08\x10-\x19"]*)")', $body, $matches); if (!$ok) { $this->raiseParseFailure( self::PARSE_MALFORMED_DOUBLE_QUOTE, pht( 'Found malformed double-quoted string in ICS document '. 'parameter value.')); } } else { $is_quoted = false; // It's impossible for this not to match since it can match // nothing, and it's valid for it to match nothing. preg_match('(^([^\x00-\x08\x10-\x19";:,]*))', $body, $matches); } // NOTE: RFC5545 says "Property parameter values that are not in // quoted-strings are case-insensitive." -- that is, the quoted and // unquoted representations are not equivalent. Thus, preserve the // original formatting in case we ever need to respect this. $param_values[] = array( 'value' => $this->unescapeParameterValue($matches[1]), 'quoted' => $is_quoted, ); $body = substr($body, strlen($matches[0])); if (!strlen($body)) { $this->raiseParseFailure( self::PARSE_MISSING_VALUE, pht( 'Expected ":" after parameters in ICS document property.')); } // If we have a comma now, we're going to read another value. Strip // it off and keep going. if ($body[0] == ',') { $body = substr($body, 1); continue; } // If we have a semicolon, we're going to read another parameter. if ($body[0] == ';') { break; } // If we have a colon, this is the last value and also the last // property. Break, then handle the colon below. if ($body[0] == ':') { break; } $short_body = id(new PhutilUTF8StringTruncator()) ->setMaximumGlyphs(32) ->truncateString($body); // We aren't expecting anything else. $this->raiseParseFailure( self::PARSE_UNEXPECTED_TEXT, pht( 'Found unexpected text ("%s") after reading parameter value.', $short_body)); } $parameters[] = array( 'name' => $param_name, 'values' => $param_values, ); if ($body[0] == ';') { $body = substr($body, 1); continue; } if ($body[0] == ':') { $body = substr($body, 1); break; } } } $value = $this->unescapeFieldValue($name, $parameters, $body); $node = $this->getNode(); $raw = $node->getAttribute('ics.properties', array()); $raw[] = array( 'name' => $name, 'parameters' => $parameters, 'value' => $value, ); $node->setAttribute('ics.properties', $raw); switch ($node->getAttribute('ics.type')) { case 'VEVENT': $this->didParseEventProperty($node, $name, $parameters, $value); break; } } private function unescapeParameterValue($data) { // The parameter grammar is adjusted by RFC6868 to permit escaping with // carets. Remove that escaping. // This escaping is a bit weird because it's trying to be backwards // compatible and the original spec didn't think about this and didn't // provide much room to fix things. $out = ''; $esc = false; foreach (phutil_utf8v($data) as $c) { if (!$esc) { if ($c != '^') { $out .= $c; } else { $esc = true; } } else { switch ($c) { case 'n': $out .= "\n"; break; case '^': $out .= '^'; break; case "'": // NOTE: This is " " being decoded into a // double quote! $out .= '"'; break; default: // NOTE: The caret is NOT an escape for any other characters. // This is a "MUST" requirement of RFC6868. $out .= '^'.$c; break; } } } // NOTE: Because caret on its own just means "caret" for backward // compatibility, we don't warn if we're still in escaped mode once we // reach the end of the string. return $out; } private function unescapeFieldValue($name, array $parameters, $data) { // NOTE: The encoding of the field value data is dependent on the field // name (which defines a default encoding) and the parameters (which may // include "VALUE", specifying a type of the data. $default_types = array( 'CALSCALE' => 'TEXT', 'METHOD' => 'TEXT', 'PRODID' => 'TEXT', 'VERSION' => 'TEXT', 'ATTACH' => 'URI', 'CATEGORIES' => 'TEXT', 'CLASS' => 'TEXT', 'COMMENT' => 'TEXT', 'DESCRIPTION' => 'TEXT', // TODO: The spec appears to contradict itself: it says that the value // type is FLOAT, but it also says that this property value is actually // two semicolon-separated values, which is not what FLOAT is defined as. 'GEO' => 'TEXT', 'LOCATION' => 'TEXT', 'PERCENT-COMPLETE' => 'INTEGER', 'PRIORITY' => 'INTEGER', 'RESOURCES' => 'TEXT', 'STATUS' => 'TEXT', 'SUMMARY' => 'TEXT', 'COMPLETED' => 'DATE-TIME', 'DTEND' => 'DATE-TIME', 'DUE' => 'DATE-TIME', 'DTSTART' => 'DATE-TIME', 'DURATION' => 'DURATION', 'FREEBUSY' => 'PERIOD', 'TRANSP' => 'TEXT', 'TZID' => 'TEXT', 'TZNAME' => 'TEXT', 'TZOFFSETFROM' => 'UTC-OFFSET', 'TZOFFSETTO' => 'UTC-OFFSET', 'TZURL' => 'URI', 'ATTENDEE' => 'CAL-ADDRESS', 'CONTACT' => 'TEXT', 'ORGANIZER' => 'CAL-ADDRESS', 'RECURRENCE-ID' => 'DATE-TIME', 'RELATED-TO' => 'TEXT', 'URL' => 'URI', 'UID' => 'TEXT', 'EXDATE' => 'DATE-TIME', 'RDATE' => 'DATE-TIME', 'RRULE' => 'RECUR', 'ACTION' => 'TEXT', 'REPEAT' => 'INTEGER', 'TRIGGER' => 'DURATION', 'CREATED' => 'DATE-TIME', 'DTSTAMP' => 'DATE-TIME', 'LAST-MODIFIED' => 'DATE-TIME', 'SEQUENCE' => 'INTEGER', 'REQUEST-STATUS' => 'TEXT', ); $value_type = idx($default_types, $name, 'TEXT'); foreach ($parameters as $parameter) { if ($parameter['name'] == 'VALUE') { $value_type = idx(head($parameter['values']), 'value'); } } switch ($value_type) { case 'BINARY': $result = base64_decode($data, true); if ($result === false) { $this->raiseParseFailure( self::PARSE_BAD_BASE64, pht( 'Unable to decode base64 data: %s', $data)); } break; case 'BOOLEAN': $map = array( 'true' => true, 'false' => false, ); $result = phutil_utf8_strtolower($data); if (!isset($map[$result])) { $this->raiseParseFailure( self::PARSE_BAD_BOOLEAN, pht( 'Unexpected BOOLEAN value "%s".', $data)); } $result = $map[$result]; break; case 'CAL-ADDRESS': $result = $data; break; case 'DATE': // This is a comma-separated list of "YYYYMMDD" values. $result = explode(',', $data); break; case 'DATE-TIME': if (!strlen($data)) { $result = array(); } else { $result = explode(',', $data); } break; case 'DURATION': if (!strlen($data)) { $result = array(); } else { $result = explode(',', $data); } break; case 'FLOAT': $result = explode(',', $data); foreach ($result as $k => $v) { $result[$k] = (float)$v; } break; case 'INTEGER': $result = explode(',', $data); foreach ($result as $k => $v) { $result[$k] = (int)$v; } break; case 'PERIOD': $result = explode(',', $data); break; case 'RECUR': $result = $data; break; case 'TEXT': $result = $this->unescapeTextValue($data); break; case 'TIME': $result = explode(',', $data); break; case 'URI': $result = $data; break; case 'UTC-OFFSET': $result = $data; break; default: // RFC5545 says we MUST preserve the data for any types we don't // recognize. $result = $data; break; } return array( 'type' => $value_type, 'value' => $result, 'raw' => $data, ); } private function unescapeTextValue($data) { $result = array(); $buf = ''; $esc = false; foreach (phutil_utf8v($data) as $c) { if (!$esc) { if ($c == '\\') { $esc = true; } else if ($c == ',') { $result[] = $buf; $buf = ''; } else { $buf .= $c; } } else { switch ($c) { case 'n': case 'N': $buf .= "\n"; break; default: $buf .= $c; break; } $esc = false; } } if ($esc) { $this->raiseParseFailure( self::PARSE_UNESCAPED_BACKSLASH, pht( 'ICS document contains TEXT value ending with unescaped '. 'backslash.')); } $result[] = $buf; return $result; } private function raiseParseFailure($code, $message) { if ($this->lines && isset($this->lines[$this->cursor])) { $message = pht( "ICS Parse Error near line %s:\n\n>>> %s\n\n%s", $this->cursor + 1, $this->lines[$this->cursor], $message); } else { $message = pht( 'ICS Parse Error: %s', $message); } throw id(new PhutilICSParserException($message)) ->setParserFailureCode($code); } private function raiseWarning($code, $message) { $this->warnings[] = array( 'code' => $code, 'line' => $this->cursor, 'text' => $this->lines[$this->cursor], 'message' => $message, ); return $this; } private function didParseEventProperty( PhutilCalendarEventNode $node, $name, array $parameters, array $value) { switch ($name) { case 'UID': $text = $this->newTextFromProperty($parameters, $value); $node->setUID($text); break; case 'CREATED': $datetime = $this->newDateTimeFromProperty($parameters, $value); $node->setCreatedDateTime($datetime); break; case 'DTSTAMP': $datetime = $this->newDateTimeFromProperty($parameters, $value); $node->setModifiedDateTime($datetime); break; case 'SUMMARY': $text = $this->newTextFromProperty($parameters, $value); $node->setName($text); break; case 'DESCRIPTION': $text = $this->newTextFromProperty($parameters, $value); $node->setDescription($text); break; case 'DTSTART': $datetime = $this->newDateTimeFromProperty($parameters, $value); $node->setStartDateTime($datetime); break; case 'DTEND': $datetime = $this->newDateTimeFromProperty($parameters, $value); $node->setEndDateTime($datetime); break; case 'DURATION': $duration = $this->newDurationFromProperty($parameters, $value); $node->setDuration($duration); break; case 'RRULE': $rrule = $this->newRecurrenceRuleFromProperty($parameters, $value); $node->setRecurrenceRule($rrule); break; case 'RECURRENCE-ID': $text = $this->newTextFromProperty($parameters, $value); $node->setRecurrenceID($text); break; + case 'ATTENDEE': + $attendee = $this->newAttendeeFromProperty($parameters, $value); + $node->addAttendee($attendee); + break; } } private function newTextFromProperty(array $parameters, array $value) { $value = $value['value']; return implode("\n\n", $value); } + private function newAttendeeFromProperty(array $parameters, array $value) { + $uri = $value['value']; + + switch (idx($parameters, 'PARTSTAT')) { + case 'ACCEPTED': + $status = PhutilCalendarUserNode::STATUS_ACCEPTED; + break; + case 'DECLINED': + $status = PhutilCalendarUserNode::STATUS_DECLINED; + break; + case 'NEEDS-ACTION': + default: + $status = PhutilCalendarUserNode::STATUS_INVITED; + break; + } + + $name = $this->getScalarParameterValue($parameters, 'CN'); + + return id(new PhutilCalendarUserNode()) + ->setURI($uri) + ->setName($name) + ->setStatus($status); + } + private function newDateTimeFromProperty(array $parameters, array $value) { $value = $value['value']; if (!$value) { $this->raiseParseFailure( self::PARSE_EMPTY_DATETIME, pht( 'Expected DATE-TIME to have exactly one value, found none.')); } if (count($value) > 1) { $this->raiseParseFailure( self::PARSE_MANY_DATETIME, pht( 'Expected DATE-TIME to have exactly one value, found more than '. 'one.')); } $value = head($value); $tzid = $this->getScalarParameterValue($parameters, 'TZID'); if (preg_match('/Z\z/', $value)) { if ($tzid) { $this->raiseWarning( self::WARN_TZID_UTC, pht( 'DATE-TIME "%s" uses "Z" to specify UTC, but also has a TZID '. 'parameter with value "%s". This violates RFC5545. The TZID '. 'will be ignored, and the value will be interpreted as UTC.', $value, $tzid)); } $tzid = 'UTC'; } else if ($tzid !== null) { $map = DateTimeZone::listIdentifiers(); $map = array_fuse($map); if (empty($map[$tzid])) { $this->raiseParseFailure( self::PARSE_BAD_TZID, pht( 'Timezone "%s" is not a recognized timezone.', $tzid)); } } try { $datetime = PhutilCalendarAbsoluteDateTime::newFromISO8601( $value, $tzid); } catch (Exception $ex) { $this->raiseParseFailure( self::PARSE_BAD_DATETIME, pht( 'Error parsing DATE-TIME: %s', $ex->getMessage())); } return $datetime; } private function newDurationFromProperty(array $parameters, array $value) { $value = $value['value']; if (!$value) { $this->raiseParseFailure( self::PARSE_EMPTY_DURATION, pht( 'Expected DURATION to have exactly one value, found none.')); } if (count($value) > 1) { $this->raiseParseFailure( self::PARSE_MANY_DURATION, pht( 'Expected DURATION to have exactly one value, found more than '. 'one.')); } $value = head($value); try { $duration = PhutilCalendarDuration::newFromISO8601($value); } catch (Exception $ex) { $this->raiseParseFailure( self::PARSE_BAD_DURATION, pht( 'Invalid DURATION: %s', $ex->getMessage())); } return $duration; } private function newRecurrenceRuleFromProperty(array $parameters, $value) { return PhutilCalendarRecurrenceRule::newFromRRULE($value['value']); } private function getScalarParameterValue( array $parameters, $name, $default = null) { $match = null; foreach ($parameters as $parameter) { if ($parameter['name'] == $name) { $match = $parameter; } } if ($match === null) { return $default; } $value = $match['values']; if (!$value) { // Parameter is specified, but with no value, like "KEY=". Just return // the default, as though the parameter was not specified. return $default; } if (count($value) > 1) { $this->raiseParseFailure( self::PARSE_MULTIPLE_PARAMETERS, pht( 'Expected parameter "%s" to have at most one value, but found '. 'more than one.', $name)); } return idx(head($value), 'value'); } } diff --git a/src/parser/calendar/ics/__tests__/PhutilICSParserTestCase.php b/src/parser/calendar/ics/__tests__/PhutilICSParserTestCase.php index a4732c3..277267f 100644 --- a/src/parser/calendar/ics/__tests__/PhutilICSParserTestCase.php +++ b/src/parser/calendar/ics/__tests__/PhutilICSParserTestCase.php @@ -1,294 +1,307 @@ parseICSSingleEvent('simple.ics'); $this->assertEqual( array( array( 'name' => 'CREATED', 'parameters' => array(), 'value' => array( 'type' => 'DATE-TIME', 'value' => array( '20160908T172702Z', ), 'raw' => '20160908T172702Z', ), ), array( 'name' => 'UID', 'parameters' => array(), 'value' => array( 'type' => 'TEXT', 'value' => array( '1CEB57AF-0C9C-402D-B3BD-D75BD4843F68', ), 'raw' => '1CEB57AF-0C9C-402D-B3BD-D75BD4843F68', ), ), array( 'name' => 'DTSTART', 'parameters' => array( array( 'name' => 'TZID', 'values' => array( array( 'value' => 'America/Los_Angeles', 'quoted' => false, ), ), ), ), 'value' => array( 'type' => 'DATE-TIME', 'value' => array( '20160915T090000', ), 'raw' => '20160915T090000', ), ), array( 'name' => 'DTEND', 'parameters' => array( array( 'name' => 'TZID', 'values' => array( array( 'value' => 'America/Los_Angeles', 'quoted' => false, ), ), ), ), 'value' => array( 'type' => 'DATE-TIME', 'value' => array( '20160915T100000', ), 'raw' => '20160915T100000', ), ), array( 'name' => 'SUMMARY', 'parameters' => array(), 'value' => array( 'type' => 'TEXT', 'value' => array( 'Simple Event', ), 'raw' => 'Simple Event', ), ), array( 'name' => 'DESCRIPTION', 'parameters' => array(), 'value' => array( 'type' => 'TEXT', 'value' => array( 'This is a simple event.', ), 'raw' => 'This is a simple event.', ), ), ), $event->getAttribute('ics.properties')); $this->assertEqual( 'Simple Event', $event->getName()); $this->assertEqual( 'This is a simple event.', $event->getDescription()); $this->assertEqual( 1473955200, $event->getStartDateTime()->getEpoch()); $this->assertEqual( 1473955200 + phutil_units('1 hour in seconds'), $event->getEndDateTime()->getEpoch()); } public function testICSFloatingTime() { // This tests "floating" event times, which have no absolute time and are // supposed to be interpreted using the viewer's timezone. It also uses // a duration, and the duration needs to float along with the viewer // timezone. $event = $this->parseICSSingleEvent('floating.ics'); $start = $event->getStartDateTime(); $caught = null; try { $start->getEpoch(); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue( ($caught instanceof Exception), pht('Expected exception for floating time with no viewer timezone.')); $newyears_utc = strtotime('2015-01-01 00:00:00 UTC'); $this->assertEqual(1420070400, $newyears_utc); $start->setViewerTimezone('UTC'); $this->assertEqual( $newyears_utc, $start->getEpoch()); $start->setViewerTimezone('America/Los_Angeles'); $this->assertEqual( $newyears_utc + phutil_units('8 hours in seconds'), $start->getEpoch()); $start->setViewerTimezone('America/New_York'); $this->assertEqual( $newyears_utc + phutil_units('5 hours in seconds'), $start->getEpoch()); $end = $event->getEndDateTime(); $end->setViewerTimezone('UTC'); $this->assertEqual( $newyears_utc + phutil_units('24 hours in seconds'), $end->getEpoch()); $end->setViewerTimezone('America/Los_Angeles'); $this->assertEqual( $newyears_utc + phutil_units('32 hours in seconds'), $end->getEpoch()); $end->setViewerTimezone('America/New_York'); $this->assertEqual( $newyears_utc + phutil_units('29 hours in seconds'), $end->getEpoch()); } + public function testICSVALARM() { + $event = $this->parseICSSingleEvent('valarm.ics'); + + // For now, we parse but ignore VALARM sections. This test just makes + // sure they survive parsing. + + $start_epoch = strtotime('2016-10-19 22:00:00 UTC'); + $this->assertEqual(1476914400, $start_epoch); + + $this->assertEqual( + $start_epoch, + $event->getStartDateTime()->getEpoch()); + } + public function testICSDuration() { $event = $this->parseICSSingleEvent('duration.ics'); // Raw value is "20160719T095722Z". $start_epoch = strtotime('2016-07-19 09:57:22 UTC'); $this->assertEqual(1468922242, $start_epoch); // Raw value is "P1DT17H4M23S". $duration = phutil_units('1 day in seconds') + phutil_units('17 hours in seconds') + phutil_units('4 minutes in seconds') + phutil_units('23 seconds in seconds'); $this->assertEqual( $start_epoch, $event->getStartDateTime()->getEpoch()); $this->assertEqual( $start_epoch + $duration, $event->getEndDateTime()->getEpoch()); } public function testICSParserErrors() { $map = array( 'err-missing-end.ics' => PhutilICSParser::PARSE_MISSING_END, 'err-bad-base64.ics' => PhutilICSParser::PARSE_BAD_BASE64, 'err-bad-boolean.ics' => PhutilICSParser::PARSE_BAD_BOOLEAN, 'err-extra-end.ics' => PhutilICSParser::PARSE_EXTRA_END, 'err-initial-unfold.ics' => PhutilICSParser::PARSE_INITIAL_UNFOLD, 'err-malformed-double-quote.ics' => PhutilICSParser::PARSE_MALFORMED_DOUBLE_QUOTE, 'err-malformed-parameter.ics' => PhutilICSParser::PARSE_MALFORMED_PARAMETER_NAME, 'err-malformed-property.ics' => PhutilICSParser::PARSE_MALFORMED_PROPERTY, 'err-missing-value.ics' => PhutilICSParser::PARSE_MISSING_VALUE, 'err-mixmatched-sections.ics' => PhutilICSParser::PARSE_MISMATCHED_SECTIONS, 'err-root-property.ics' => PhutilICSParser::PARSE_ROOT_PROPERTY, 'err-unescaped-backslash.ics' => PhutilICSParser::PARSE_UNESCAPED_BACKSLASH, - 'err-unexpected-child.ics' => PhutilICSParser::PARSE_UNEXPECTED_CHILD, 'err-unexpected-text.ics' => PhutilICSParser::PARSE_UNEXPECTED_TEXT, 'err-multiple-parameters.ics' => PhutilICSParser::PARSE_MULTIPLE_PARAMETERS, 'err-empty-datetime.ics' => PhutilICSParser::PARSE_EMPTY_DATETIME, 'err-many-datetime.ics' => PhutilICSParser::PARSE_MANY_DATETIME, 'err-bad-datetime.ics' => PhutilICSParser::PARSE_BAD_DATETIME, 'err-bad-tzid.ics' => PhutilICSParser::PARSE_BAD_TZID, 'err-empty-duration.ics' => PhutilICSParser::PARSE_EMPTY_DURATION, 'err-many-duration.ics' => PhutilICSParser::PARSE_MANY_DURATION, 'err-bad-duration.ics' => PhutilICSParser::PARSE_BAD_DURATION, 'simple.ics' => null, 'good-boolean.ics' => null, 'multiple-vcalendars.ics' => null, ); foreach ($map as $test_file => $expect) { $caught = null; try { $this->parseICSDocument($test_file); } catch (PhutilICSParserException $ex) { $caught = $ex; } if ($expect === null) { $this->assertTrue( ($caught === null), pht( 'Expected no exception parsing "%s", got: %s', $test_file, (string)$ex)); } else { if ($caught) { $code = $ex->getParserFailureCode(); $explain = pht( 'Expected one exception parsing "%s", got a different '. 'one: %s', $test_file, (string)$ex); } else { $code = null; $explain = pht( 'Expected exception parsing "%s", got none.', $test_file); } $this->assertEqual($expect, $code, $explain); } } } private function parseICSSingleEvent($name) { $root = $this->parseICSDocument($name); $documents = $root->getDocuments(); $this->assertEqual(1, count($documents)); $document = head($documents); $events = $document->getEvents(); $this->assertEqual(1, count($events)); return head($events); } private function parseICSDocument($name) { $path = dirname(__FILE__).'/data/'.$name; $data = Filesystem::readFile($path); return id(new PhutilICSParser()) ->parseICSData($data); } } diff --git a/src/parser/calendar/ics/__tests__/data/err-unexpected-child.ics b/src/parser/calendar/ics/__tests__/data/err-unexpected-child.ics deleted file mode 100644 index 0478c40..0000000 --- a/src/parser/calendar/ics/__tests__/data/err-unexpected-child.ics +++ /dev/null @@ -1,6 +0,0 @@ -BEGIN:VCALENDAR -BEGIN:VEVENT -BEGIN:TEST -END:TEST -END:VEVENT -END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/valarm.ics b/src/parser/calendar/ics/__tests__/data/valarm.ics new file mode 100644 index 0000000..060f5ef --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/valarm.ics @@ -0,0 +1,16 @@ +BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +CREATED:20161027T173727 +DTSTAMP:20161027T173727 +LAST-MODIFIED:20161027T173727 +UID:aic4zm86mg +SUMMARY:alarm event +DTSTART;TZID=Europe/Berlin:20161020T000000 +DTEND;TZID=Europe/Berlin:20161020T010000 +BEGIN:VALARM +ACTION:AUDIO +TRIGGER:-PT15M +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/src/symbols/PhutilClassMapQuery.php b/src/symbols/PhutilClassMapQuery.php index 7ccb99c..272a15b 100644 --- a/src/symbols/PhutilClassMapQuery.php +++ b/src/symbols/PhutilClassMapQuery.php @@ -1,307 +1,318 @@ ancestorClass = $class; return $this; } /** * Provide a method to select a unique key for each instance. * * If you provide a method here, the map will be keyed with these values, * instead of with class names. Exceptions will be raised if entries are * not unique. * * You must provide a method here to use @{method:setExpandMethod}. * * @param string Name of the unique key method. * @param bool If true, then classes which return `null` will be filtered * from the results. * @return this * @task config */ public function setUniqueMethod($unique_method, $filter_null = false) { $this->uniqueMethod = $unique_method; $this->filterNull = $filter_null; return $this; } /** * Provide a method to expand each concrete subclass into available instances. * * With some class maps, each class is allowed to provide multiple entries * in the map by returning alternatives from some method with a default * implementation like this: * * public function generateVariants() { * return array($this); * } * * For example, a "color" class may really generate and configure several * instances in the final class map: * * public function generateVariants() { * return array( * self::newColor('red'), * self::newColor('green'), * self::newColor('blue'), * ); * } * * This allows multiple entires in the final map to share an entire * implementation, rather than requiring that they each have their own unique * subclass. * * This pattern is most useful if several variants are nearly identical (so * the stub subclasses would be essentially empty) or the available variants * are driven by configuration. * * If a class map uses this pattern, it must also provide a unique key for * each instance with @{method:setUniqueMethod}. * * @param string Name of the expansion method. * @return this * @task config */ public function setExpandMethod($expand_method) { $this->expandMethod = $expand_method; return $this; } /** * Provide a method to sort the final map. * * The map will be sorted using @{function:msort} and passing this method * name. * * @param string Name of the sorting method. * @return this * @task config */ public function setSortMethod($sort_method) { $this->sortMethod = $sort_method; return $this; } /** * Provide a method to filter the map. * * @param string Name of the filtering method. * @return this * @task config */ public function setFilterMethod($filter_method) { $this->filterMethod = $filter_method; return $this; } /* -( Executing the Query )------------------------------------------------ */ /** * Execute the query as configured. * * @return map Realized class map. * @task exec */ public function execute() { $cache_key = $this->getCacheKey(); if (!isset(self::$cache[$cache_key])) { self::$cache[$cache_key] = $this->loadMap(); } return self::$cache[$cache_key]; } + /** + * Delete all class map caches. + * + * @return void + * @task exec + */ + public static function deleteCaches() { + self::$cache = array(); + } + + /** * Generate the core query results. * * This method is used to fill the cache. * * @return map Realized class map. * @task exec */ private function loadMap() { $ancestor = $this->ancestorClass; if (!strlen($ancestor)) { throw new PhutilInvalidStateException('setAncestorClass'); } if (!class_exists($ancestor) && !interface_exists($ancestor)) { throw new Exception( pht( 'Trying to execute a class map query for descendants of class '. '"%s", but no such class or interface exists.', $ancestor)); } $expand = $this->expandMethod; $filter = $this->filterMethod; $unique = $this->uniqueMethod; $sort = $this->sortMethod; if (strlen($expand)) { if (!strlen($unique)) { throw new Exception( pht( 'Trying to execute a class map query for descendants of class '. '"%s", but the query specifies an "expand method" ("%s") without '. 'specifying a "unique method". Class maps which support expansion '. 'must have unique keys.', $ancestor, $expand)); } } $objects = id(new PhutilSymbolLoader()) ->setAncestorClass($ancestor) ->loadObjects(); // Apply the "expand" mechanism, if it is configured. if (strlen($expand)) { $list = array(); foreach ($objects as $object) { foreach (call_user_func(array($object, $expand)) as $instance) { $list[] = $instance; } } } else { $list = $objects; } // Apply the "unique" mechanism, if it is configured. if (strlen($unique)) { $map = array(); foreach ($list as $object) { $key = call_user_func(array($object, $unique)); if ($key === null && $this->filterNull) { continue; } if (empty($map[$key])) { $map[$key] = $object; continue; } throw new Exception( pht( 'Two objects (of classes "%s" and "%s", descendants of ancestor '. 'class "%s") returned the same key from "%s" ("%s"), but each '. 'object in this class map must be identified by a unique key.', get_class($object), get_class($map[$key]), $ancestor, $unique.'()', $key)); } } else { $map = $list; } // Apply the "filter" mechanism, if it is configured. if (strlen($filter)) { $map = mfilter($map, $filter); } // Apply the "sort" mechanism, if it is configured. if (strlen($sort)) { $map = msort($map, $sort); } return $map; } /* -( Managing the Map Cache )--------------------------------------------- */ /** * Return a cache key for this query. * * @return string Cache key. * @task cache */ private function getCacheKey() { $parts = array( $this->ancestorClass, $this->uniqueMethod, $this->filterNull, $this->expandMethod, $this->filterMethod, $this->sortMethod, ); return implode(':', $parts); } }