diff --git a/scripts/__init_script__.php b/scripts/__init_script__.php index d4559e9..53cb235 100644 --- a/scripts/__init_script__.php +++ b/scripts/__init_script__.php @@ -1,101 +1,91 @@ 0) { ob_end_clean(); } error_reporting(E_ALL | E_STRICT); $config_map = array( // Always display script errors. Without this, they may not appear, which is // unhelpful when users encounter a problem. On the web this is a security // concern because you don't want to expose errors to clients, but in a // script context we always want to show errors. 'display_errors' => true, // Send script error messages to the server's `error_log` setting. 'log_errors' => true, // Set the error log to the default, so errors go to stderr. Without this // errors may end up in some log, and users may not know where the log is // or check it. 'error_log' => null, // XDebug raises a fatal error if the call stack gets too deep, but the // default setting is 100, which we may exceed legitimately with module // includes (and in other cases, like recursive filesystem operations // applied to 100+ levels of directory nesting). Stop it from triggering: // we explicitly limit recursive algorithms which should be limited. // // After Feb 2014, XDebug interprets a value of 0 to mean "do not allow any // function calls". Previously, 0 effectively disabled this check. For // context, see T5027. 'xdebug.max_nesting_level' => PHP_INT_MAX, // Don't limit memory, doing so just generally just prevents us from // processing large inputs without many tangible benefits. 'memory_limit' => -1, ); foreach ($config_map as $config_key => $config_value) { ini_set($config_key, $config_value); } if (!ini_get('date.timezone')) { // If the timezone isn't set, PHP issues a warning whenever you try to parse // a date (like those from Git or Mercurial logs), even if the date contains // timezone information (like "PST" or "-0700") which makes the // environmental timezone setting is completely irrelevant. We never rely on // the system timezone setting in any capacity, so prevent PHP from flipping // out by setting it to a safe default (UTC) if it isn't set to some other // value. date_default_timezone_set('UTC'); } // Adjust `include_path`. ini_set('include_path', implode(PATH_SEPARATOR, array( dirname(dirname(__FILE__)).'/externals/includes', ini_get('include_path'), ))); // Disable the insanely dangerous XML entity loader by default. if (function_exists('libxml_disable_entity_loader')) { libxml_disable_entity_loader(true); } // Now, load libphutil. $root = dirname(dirname(__FILE__)); require_once $root.'/src/__phutil_library_init__.php'; PhutilErrorHandler::initialize(); + $router = PhutilSignalRouter::initialize(); - // If possible, install a signal handler for SIGHUP which prints the current - // backtrace out to a named file. This is particularly helpful in debugging - // hung/spinning processes. - if (function_exists('pcntl_signal')) { - pcntl_signal(SIGHUP, '__phutil_signal_handler__'); - } -} - -function __phutil_signal_handler__($signal_number) { - $e = new Exception(); - $pid = getmypid(); - // Some Phabricator daemons may not be attached to a terminal. - Filesystem::writeFile( - sys_get_temp_dir().'/phabricator_backtrace_'.$pid, - $e->getTraceAsString()); + $handler = new PhutilBacktraceSignalHandler(); + $router->installHandler('phutil.backtrace', $handler); } __phutil_init_script__(); diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 5e66139..9569f9a 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1,996 +1,1042 @@ 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', + 'PhutilCalendarRelativeDateTime' => 'parser/calendar/data/PhutilCalendarRelativeDateTime.php', + 'PhutilCalendarRootNode' => 'parser/calendar/data/PhutilCalendarRootNode.php', + 'PhutilCalendarUserNode' => 'parser/calendar/data/PhutilCalendarUserNode.php', 'PhutilCallbackFilterIterator' => 'utils/PhutilCallbackFilterIterator.php', + 'PhutilCallbackSignalHandler' => 'future/exec/PhutilCallbackSignalHandler.php', 'PhutilChannel' => 'channel/PhutilChannel.php', 'PhutilChannelChannel' => 'channel/PhutilChannelChannel.php', 'PhutilChannelTestCase' => 'channel/__tests__/PhutilChannelTestCase.php', 'PhutilChunkedIterator' => 'utils/PhutilChunkedIterator.php', 'PhutilChunkedIteratorTestCase' => 'utils/__tests__/PhutilChunkedIteratorTestCase.php', 'PhutilClassMapQuery' => 'symbols/PhutilClassMapQuery.php', 'PhutilCodeSnippetContextFreeGrammar' => 'grammar/code/PhutilCodeSnippetContextFreeGrammar.php', 'PhutilCommandString' => 'xsprintf/PhutilCommandString.php', 'PhutilConsole' => 'console/PhutilConsole.php', 'PhutilConsoleBlock' => 'console/view/PhutilConsoleBlock.php', 'PhutilConsoleConcatenatedView' => 'console/view/PhutilConsoleConcatenatedView.php', 'PhutilConsoleFormatter' => 'console/PhutilConsoleFormatter.php', 'PhutilConsoleList' => 'console/view/PhutilConsoleList.php', 'PhutilConsoleMessage' => 'console/PhutilConsoleMessage.php', 'PhutilConsoleProgressBar' => 'console/PhutilConsoleProgressBar.php', 'PhutilConsoleServer' => 'console/PhutilConsoleServer.php', 'PhutilConsoleServerChannel' => 'console/PhutilConsoleServerChannel.php', 'PhutilConsoleStdinNotInteractiveException' => 'console/PhutilConsoleStdinNotInteractiveException.php', 'PhutilConsoleSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilConsoleSyntaxHighlighter.php', 'PhutilConsoleTable' => 'console/view/PhutilConsoleTable.php', 'PhutilConsoleView' => 'console/view/PhutilConsoleView.php', 'PhutilConsoleWrapTestCase' => 'console/__tests__/PhutilConsoleWrapTestCase.php', 'PhutilContextFreeGrammar' => 'grammar/PhutilContextFreeGrammar.php', 'PhutilCowsay' => 'utils/PhutilCowsay.php', 'PhutilCowsayTestCase' => 'utils/__tests__/PhutilCowsayTestCase.php', 'PhutilCsprintfTestCase' => 'xsprintf/__tests__/PhutilCsprintfTestCase.php', 'PhutilCzechLocale' => 'internationalization/locales/PhutilCzechLocale.php', 'PhutilDaemon' => 'daemon/PhutilDaemon.php', 'PhutilDaemonHandle' => 'daemon/PhutilDaemonHandle.php', 'PhutilDaemonOverseer' => 'daemon/PhutilDaemonOverseer.php', 'PhutilDaemonOverseerModule' => 'daemon/PhutilDaemonOverseerModule.php', 'PhutilDefaultSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilDefaultSyntaxHighlighter.php', 'PhutilDefaultSyntaxHighlighterEngine' => 'markup/syntax/engine/PhutilDefaultSyntaxHighlighterEngine.php', 'PhutilDefaultSyntaxHighlighterEnginePygmentsFuture' => 'markup/syntax/highlighter/pygments/PhutilDefaultSyntaxHighlighterEnginePygmentsFuture.php', 'PhutilDefaultSyntaxHighlighterEngineTestCase' => 'markup/syntax/engine/__tests__/PhutilDefaultSyntaxHighlighterEngineTestCase.php', 'PhutilDeferredLog' => 'filesystem/PhutilDeferredLog.php', 'PhutilDeferredLogTestCase' => 'filesystem/__tests__/PhutilDeferredLogTestCase.php', 'PhutilDirectedScalarGraph' => 'utils/PhutilDirectedScalarGraph.php', 'PhutilDirectoryFixture' => 'filesystem/PhutilDirectoryFixture.php', 'PhutilDirectoryKeyValueCache' => 'cache/PhutilDirectoryKeyValueCache.php', 'PhutilDisqusAuthAdapter' => 'auth/PhutilDisqusAuthAdapter.php', 'PhutilDivinerSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilDivinerSyntaxHighlighter.php', 'PhutilDocblockParser' => 'parser/PhutilDocblockParser.php', 'PhutilDocblockParserTestCase' => 'parser/__tests__/PhutilDocblockParserTestCase.php', 'PhutilEditDistanceMatrix' => 'utils/PhutilEditDistanceMatrix.php', 'PhutilEditDistanceMatrixTestCase' => 'utils/__tests__/PhutilEditDistanceMatrixTestCase.php', 'PhutilEditorConfig' => 'parser/PhutilEditorConfig.php', 'PhutilEditorConfigTestCase' => 'parser/__tests__/PhutilEditorConfigTestCase.php', 'PhutilEmailAddress' => 'parser/PhutilEmailAddress.php', 'PhutilEmailAddressTestCase' => 'parser/__tests__/PhutilEmailAddressTestCase.php', 'PhutilEmojiLocale' => 'internationalization/locales/PhutilEmojiLocale.php', 'PhutilEmptyAuthAdapter' => 'auth/PhutilEmptyAuthAdapter.php', 'PhutilEnglishCanadaLocale' => 'internationalization/locales/PhutilEnglishCanadaLocale.php', 'PhutilErrorHandler' => 'error/PhutilErrorHandler.php', 'PhutilErrorHandlerTestCase' => 'error/__tests__/PhutilErrorHandlerTestCase.php', 'PhutilErrorTrap' => 'error/PhutilErrorTrap.php', 'PhutilEvent' => 'events/PhutilEvent.php', 'PhutilEventConstants' => 'events/constant/PhutilEventConstants.php', 'PhutilEventEngine' => 'events/PhutilEventEngine.php', 'PhutilEventListener' => 'events/PhutilEventListener.php', 'PhutilEventType' => 'events/constant/PhutilEventType.php', 'PhutilExampleBufferedIterator' => 'utils/PhutilExampleBufferedIterator.php', 'PhutilExcessiveServiceCallsDaemon' => 'daemon/torture/PhutilExcessiveServiceCallsDaemon.php', 'PhutilExecChannel' => 'channel/PhutilExecChannel.php', 'PhutilExecPassthru' => 'future/exec/PhutilExecPassthru.php', 'PhutilExecutableFuture' => 'future/exec/PhutilExecutableFuture.php', 'PhutilExecutionEnvironment' => 'utils/PhutilExecutionEnvironment.php', 'PhutilExtensionsTestCase' => 'moduleutils/__tests__/PhutilExtensionsTestCase.php', 'PhutilFacebookAuthAdapter' => 'auth/PhutilFacebookAuthAdapter.php', 'PhutilFatalDaemon' => 'daemon/torture/PhutilFatalDaemon.php', 'PhutilFileLock' => 'filesystem/PhutilFileLock.php', 'PhutilFileLockTestCase' => 'filesystem/__tests__/PhutilFileLockTestCase.php', 'PhutilFileTree' => 'filesystem/PhutilFileTree.php', 'PhutilFrenchLocale' => 'internationalization/locales/PhutilFrenchLocale.php', 'PhutilGermanLocale' => 'internationalization/locales/PhutilGermanLocale.php', 'PhutilGitHubAuthAdapter' => 'auth/PhutilGitHubAuthAdapter.php', 'PhutilGitHubFuture' => 'future/github/PhutilGitHubFuture.php', 'PhutilGitHubResponse' => 'future/github/PhutilGitHubResponse.php', 'PhutilGitURI' => 'parser/PhutilGitURI.php', 'PhutilGitURITestCase' => 'parser/__tests__/PhutilGitURITestCase.php', 'PhutilGoogleAuthAdapter' => 'auth/PhutilGoogleAuthAdapter.php', 'PhutilHTTPEngineExtension' => 'future/http/PhutilHTTPEngineExtension.php', 'PhutilHangForeverDaemon' => 'daemon/torture/PhutilHangForeverDaemon.php', 'PhutilHashingIterator' => 'utils/PhutilHashingIterator.php', 'PhutilHashingIteratorTestCase' => 'utils/__tests__/PhutilHashingIteratorTestCase.php', 'PhutilHelpArgumentWorkflow' => 'parser/argument/workflow/PhutilHelpArgumentWorkflow.php', 'PhutilHgsprintfTestCase' => 'xsprintf/__tests__/PhutilHgsprintfTestCase.php', 'PhutilHighIntensityIntervalDaemon' => 'daemon/torture/PhutilHighIntensityIntervalDaemon.php', + 'PhutilICSParser' => 'parser/calendar/ics/PhutilICSParser.php', + 'PhutilICSParserException' => 'parser/calendar/ics/PhutilICSParserException.php', + 'PhutilICSParserTestCase' => 'parser/calendar/ics/__tests__/PhutilICSParserTestCase.php', + 'PhutilICSWriter' => 'parser/calendar/ics/PhutilICSWriter.php', + 'PhutilICSWriterTestCase' => 'parser/calendar/ics/__tests__/PhutilICSWriterTestCase.php', 'PhutilINIParserException' => 'parser/exception/PhutilINIParserException.php', 'PhutilIPAddress' => 'ip/PhutilIPAddress.php', 'PhutilIPAddressTestCase' => 'ip/__tests__/PhutilIPAddressTestCase.php', 'PhutilInRequestKeyValueCache' => 'cache/PhutilInRequestKeyValueCache.php', 'PhutilInteractiveEditor' => 'console/PhutilInteractiveEditor.php', 'PhutilInvalidRuleParserGeneratorException' => 'parser/generator/exception/PhutilInvalidRuleParserGeneratorException.php', 'PhutilInvalidStateException' => 'exception/PhutilInvalidStateException.php', 'PhutilInvalidStateExceptionTestCase' => 'exception/__tests__/PhutilInvalidStateExceptionTestCase.php', 'PhutilInvisibleSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilInvisibleSyntaxHighlighter.php', 'PhutilIrreducibleRuleParserGeneratorException' => 'parser/generator/exception/PhutilIrreducibleRuleParserGeneratorException.php', 'PhutilJIRAAuthAdapter' => 'auth/PhutilJIRAAuthAdapter.php', 'PhutilJSON' => 'parser/PhutilJSON.php', 'PhutilJSONFragmentLexer' => 'lexer/PhutilJSONFragmentLexer.php', 'PhutilJSONFragmentLexerHighlighterTestCase' => 'markup/syntax/highlighter/__tests__/PhutilJSONFragmentLexerHighlighterTestCase.php', 'PhutilJSONParser' => 'parser/PhutilJSONParser.php', 'PhutilJSONParserException' => 'parser/exception/PhutilJSONParserException.php', 'PhutilJSONParserTestCase' => 'parser/__tests__/PhutilJSONParserTestCase.php', 'PhutilJSONProtocolChannel' => 'channel/PhutilJSONProtocolChannel.php', 'PhutilJSONProtocolChannelTestCase' => 'channel/__tests__/PhutilJSONProtocolChannelTestCase.php', 'PhutilJSONTestCase' => 'parser/__tests__/PhutilJSONTestCase.php', 'PhutilJavaCodeSnippetContextFreeGrammar' => 'grammar/code/PhutilJavaCodeSnippetContextFreeGrammar.php', 'PhutilKeyValueCache' => 'cache/PhutilKeyValueCache.php', 'PhutilKeyValueCacheNamespace' => 'cache/PhutilKeyValueCacheNamespace.php', 'PhutilKeyValueCacheProfiler' => 'cache/PhutilKeyValueCacheProfiler.php', 'PhutilKeyValueCacheProxy' => 'cache/PhutilKeyValueCacheProxy.php', 'PhutilKeyValueCacheStack' => 'cache/PhutilKeyValueCacheStack.php', 'PhutilKeyValueCacheTestCase' => 'cache/__tests__/PhutilKeyValueCacheTestCase.php', 'PhutilKoreanLocale' => 'internationalization/locales/PhutilKoreanLocale.php', 'PhutilLDAPAuthAdapter' => 'auth/PhutilLDAPAuthAdapter.php', 'PhutilLanguageGuesser' => 'parser/PhutilLanguageGuesser.php', 'PhutilLanguageGuesserTestCase' => 'parser/__tests__/PhutilLanguageGuesserTestCase.php', 'PhutilLexer' => 'lexer/PhutilLexer.php', 'PhutilLexerSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilLexerSyntaxHighlighter.php', 'PhutilLibraryConflictException' => 'moduleutils/PhutilLibraryConflictException.php', 'PhutilLibraryMapBuilder' => 'moduleutils/PhutilLibraryMapBuilder.php', 'PhutilLibraryTestCase' => '__tests__/PhutilLibraryTestCase.php', 'PhutilLipsumContextFreeGrammar' => 'grammar/PhutilLipsumContextFreeGrammar.php', 'PhutilLocale' => 'internationalization/PhutilLocale.php', 'PhutilLocaleTestCase' => 'internationalization/__tests__/PhutilLocaleTestCase.php', 'PhutilLock' => 'filesystem/PhutilLock.php', 'PhutilLockException' => 'filesystem/PhutilLockException.php', 'PhutilLogFileChannel' => 'channel/PhutilLogFileChannel.php', 'PhutilLunarPhase' => 'utils/PhutilLunarPhase.php', 'PhutilLunarPhaseTestCase' => 'utils/__tests__/PhutilLunarPhaseTestCase.php', 'PhutilMarkupEngine' => 'markup/PhutilMarkupEngine.php', 'PhutilMarkupTestCase' => 'markup/__tests__/PhutilMarkupTestCase.php', 'PhutilMemcacheKeyValueCache' => 'cache/PhutilMemcacheKeyValueCache.php', 'PhutilMethodNotImplementedException' => 'error/PhutilMethodNotImplementedException.php', 'PhutilMetricsChannel' => 'channel/PhutilMetricsChannel.php', 'PhutilMissingSymbolException' => 'symbols/exception/PhutilMissingSymbolException.php', 'PhutilModuleUtilsTestCase' => 'moduleutils/__tests__/PhutilModuleUtilsTestCase.php', 'PhutilNiceDaemon' => 'daemon/torture/PhutilNiceDaemon.php', 'PhutilNumber' => 'internationalization/PhutilNumber.php', 'PhutilOAuth1AuthAdapter' => 'auth/PhutilOAuth1AuthAdapter.php', 'PhutilOAuth1Future' => 'future/oauth/PhutilOAuth1Future.php', 'PhutilOAuth1FutureTestCase' => 'future/oauth/__tests__/PhutilOAuth1FutureTestCase.php', 'PhutilOAuthAuthAdapter' => 'auth/PhutilOAuthAuthAdapter.php', 'PhutilOnDiskKeyValueCache' => 'cache/PhutilOnDiskKeyValueCache.php', 'PhutilOpaqueEnvelope' => 'error/PhutilOpaqueEnvelope.php', 'PhutilOpaqueEnvelopeKey' => 'error/PhutilOpaqueEnvelopeKey.php', 'PhutilOpaqueEnvelopeTestCase' => 'error/__tests__/PhutilOpaqueEnvelopeTestCase.php', 'PhutilPHPCodeSnippetContextFreeGrammar' => 'grammar/code/PhutilPHPCodeSnippetContextFreeGrammar.php', 'PhutilPHPFragmentLexer' => 'lexer/PhutilPHPFragmentLexer.php', 'PhutilPHPFragmentLexerHighlighterTestCase' => 'markup/syntax/highlighter/__tests__/PhutilPHPFragmentLexerHighlighterTestCase.php', 'PhutilPHPFragmentLexerTestCase' => 'lexer/__tests__/PhutilPHPFragmentLexerTestCase.php', 'PhutilPHPObjectProtocolChannel' => 'channel/PhutilPHPObjectProtocolChannel.php', 'PhutilPHPObjectProtocolChannelTestCase' => 'channel/__tests__/PhutilPHPObjectProtocolChannelTestCase.php', 'PhutilParserGenerator' => 'parser/PhutilParserGenerator.php', 'PhutilParserGeneratorException' => 'parser/generator/exception/PhutilParserGeneratorException.php', 'PhutilParserGeneratorTestCase' => 'parser/__tests__/PhutilParserGeneratorTestCase.php', 'PhutilPayPalAPIFuture' => 'future/paypal/PhutilPayPalAPIFuture.php', 'PhutilPerson' => 'internationalization/PhutilPerson.php', 'PhutilPersonTest' => 'internationalization/__tests__/PhutilPersonTest.php', 'PhutilPersonaAuthAdapter' => 'auth/PhutilPersonaAuthAdapter.php', 'PhutilPhabricatorAuthAdapter' => 'auth/PhutilPhabricatorAuthAdapter.php', 'PhutilPhtTestCase' => 'internationalization/__tests__/PhutilPhtTestCase.php', 'PhutilPirateEnglishLocale' => 'internationalization/locales/PhutilPirateEnglishLocale.php', 'PhutilPortugueseBrazilLocale' => 'internationalization/locales/PhutilPortugueseBrazilLocale.php', 'PhutilPortuguesePortugalLocale' => 'internationalization/locales/PhutilPortuguesePortugalLocale.php', 'PhutilPregsprintfTestCase' => 'xsprintf/__tests__/PhutilPregsprintfTestCase.php', 'PhutilProcessGroupDaemon' => 'daemon/torture/PhutilProcessGroupDaemon.php', 'PhutilProseDiff' => 'utils/PhutilProseDiff.php', 'PhutilProseDiffTestCase' => 'utils/__tests__/PhutilProseDiffTestCase.php', 'PhutilProseDifferenceEngine' => 'utils/PhutilProseDifferenceEngine.php', 'PhutilProtocolChannel' => 'channel/PhutilProtocolChannel.php', 'PhutilProxyException' => 'error/PhutilProxyException.php', 'PhutilProxyIterator' => 'utils/PhutilProxyIterator.php', 'PhutilPygmentizeParser' => 'parser/PhutilPygmentizeParser.php', 'PhutilPygmentizeParserTestCase' => 'parser/__tests__/PhutilPygmentizeParserTestCase.php', 'PhutilPygmentsSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilPygmentsSyntaxHighlighter.php', 'PhutilPythonFragmentLexer' => 'lexer/PhutilPythonFragmentLexer.php', 'PhutilQsprintfInterface' => 'xsprintf/PhutilQsprintfInterface.php', 'PhutilQueryStringParser' => 'parser/PhutilQueryStringParser.php', 'PhutilQueryStringParserTestCase' => 'parser/__tests__/PhutilQueryStringParserTestCase.php', 'PhutilRainbowSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilRainbowSyntaxHighlighter.php', 'PhutilRawEnglishLocale' => 'internationalization/locales/PhutilRawEnglishLocale.php', 'PhutilReadableSerializer' => 'readableserializer/PhutilReadableSerializer.php', 'PhutilReadableSerializerTestCase' => 'readableserializer/__tests__/PhutilReadableSerializerTestCase.php', 'PhutilRealNameContextFreeGrammar' => 'grammar/PhutilRealNameContextFreeGrammar.php', 'PhutilRemarkupBlockInterpreter' => 'markup/engine/remarkup/blockrule/PhutilRemarkupBlockInterpreter.php', 'PhutilRemarkupBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupBlockRule.php', 'PhutilRemarkupBlockStorage' => 'markup/engine/remarkup/PhutilRemarkupBlockStorage.php', 'PhutilRemarkupBoldRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupBoldRule.php', 'PhutilRemarkupCodeBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupCodeBlockRule.php', 'PhutilRemarkupDefaultBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupDefaultBlockRule.php', 'PhutilRemarkupDelRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupDelRule.php', 'PhutilRemarkupDocumentLinkRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupDocumentLinkRule.php', 'PhutilRemarkupEngine' => 'markup/engine/PhutilRemarkupEngine.php', 'PhutilRemarkupEngineTestCase' => 'markup/engine/__tests__/PhutilRemarkupEngineTestCase.php', 'PhutilRemarkupEscapeRemarkupRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupEscapeRemarkupRule.php', 'PhutilRemarkupHeaderBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupHeaderBlockRule.php', 'PhutilRemarkupHighlightRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupHighlightRule.php', 'PhutilRemarkupHorizontalRuleBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupHorizontalRuleBlockRule.php', 'PhutilRemarkupHyperlinkRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkRule.php', 'PhutilRemarkupInlineBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupInlineBlockRule.php', 'PhutilRemarkupInterpreterBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupInterpreterBlockRule.php', 'PhutilRemarkupItalicRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupItalicRule.php', 'PhutilRemarkupLinebreaksRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupLinebreaksRule.php', 'PhutilRemarkupListBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupListBlockRule.php', 'PhutilRemarkupLiteralBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupLiteralBlockRule.php', 'PhutilRemarkupMonospaceRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupMonospaceRule.php', 'PhutilRemarkupNoteBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupNoteBlockRule.php', 'PhutilRemarkupQuotesBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupQuotesBlockRule.php', 'PhutilRemarkupReplyBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupReplyBlockRule.php', 'PhutilRemarkupRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupRule.php', 'PhutilRemarkupSimpleTableBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupSimpleTableBlockRule.php', 'PhutilRemarkupTableBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupTableBlockRule.php', 'PhutilRemarkupTestInterpreterRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupTestInterpreterRule.php', 'PhutilRemarkupUnderlineRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupUnderlineRule.php', 'PhutilRope' => 'utils/PhutilRope.php', 'PhutilRopeTestCase' => 'utils/__tests__/PhutilRopeTestCase.php', 'PhutilSafeHTML' => 'markup/PhutilSafeHTML.php', 'PhutilSafeHTMLProducerInterface' => 'markup/PhutilSafeHTMLProducerInterface.php', 'PhutilSafeHTMLTestCase' => 'markup/__tests__/PhutilSafeHTMLTestCase.php', 'PhutilSaturateStdoutDaemon' => 'daemon/torture/PhutilSaturateStdoutDaemon.php', 'PhutilServiceProfiler' => 'serviceprofiler/PhutilServiceProfiler.php', 'PhutilShellLexer' => 'lexer/PhutilShellLexer.php', 'PhutilShellLexerTestCase' => 'lexer/__tests__/PhutilShellLexerTestCase.php', + 'PhutilSignalHandler' => 'future/exec/PhutilSignalHandler.php', + 'PhutilSignalRouter' => 'future/exec/PhutilSignalRouter.php', 'PhutilSimpleOptions' => 'parser/PhutilSimpleOptions.php', 'PhutilSimpleOptionsLexer' => 'lexer/PhutilSimpleOptionsLexer.php', 'PhutilSimpleOptionsLexerTestCase' => 'lexer/__tests__/PhutilSimpleOptionsLexerTestCase.php', 'PhutilSimpleOptionsTestCase' => 'parser/__tests__/PhutilSimpleOptionsTestCase.php', 'PhutilSimplifiedChineseLocale' => 'internationalization/locales/PhutilSimplifiedChineseLocale.php', + 'PhutilSlackAuthAdapter' => 'auth/PhutilSlackAuthAdapter.php', + 'PhutilSlackFuture' => 'future/slack/PhutilSlackFuture.php', 'PhutilSocketChannel' => 'channel/PhutilSocketChannel.php', 'PhutilSortVector' => 'utils/PhutilSortVector.php', 'PhutilSpanishSpainLocale' => 'internationalization/locales/PhutilSpanishSpainLocale.php', 'PhutilSprite' => 'sprites/PhutilSprite.php', 'PhutilSpriteSheet' => 'sprites/PhutilSpriteSheet.php', 'PhutilStreamIterator' => 'utils/PhutilStreamIterator.php', 'PhutilSymbolLoader' => 'symbols/PhutilSymbolLoader.php', 'PhutilSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilSyntaxHighlighter.php', 'PhutilSyntaxHighlighterEngine' => 'markup/syntax/engine/PhutilSyntaxHighlighterEngine.php', 'PhutilSyntaxHighlighterException' => 'markup/syntax/highlighter/PhutilSyntaxHighlighterException.php', 'PhutilSystem' => 'utils/PhutilSystem.php', 'PhutilSystemTestCase' => 'utils/__tests__/PhutilSystemTestCase.php', 'PhutilTerminalString' => 'xsprintf/PhutilTerminalString.php', 'PhutilTestPhobject' => 'object/__tests__/PhutilTestPhobject.php', 'PhutilTortureTestDaemon' => 'daemon/torture/PhutilTortureTestDaemon.php', 'PhutilTraditionalChineseLocale' => 'internationalization/locales/PhutilTraditionalChineseLocale.php', 'PhutilTranslation' => 'internationalization/PhutilTranslation.php', 'PhutilTranslationTestCase' => 'internationalization/__tests__/PhutilTranslationTestCase.php', 'PhutilTranslator' => 'internationalization/PhutilTranslator.php', 'PhutilTranslatorTestCase' => 'internationalization/__tests__/PhutilTranslatorTestCase.php', 'PhutilTsprintfTestCase' => 'xsprintf/__tests__/PhutilTsprintfTestCase.php', 'PhutilTwitchAuthAdapter' => 'auth/PhutilTwitchAuthAdapter.php', 'PhutilTwitchFuture' => 'future/twitch/PhutilTwitchFuture.php', 'PhutilTwitterAuthAdapter' => 'auth/PhutilTwitterAuthAdapter.php', 'PhutilTypeCheckException' => 'parser/exception/PhutilTypeCheckException.php', 'PhutilTypeExtraParametersException' => 'parser/exception/PhutilTypeExtraParametersException.php', 'PhutilTypeLexer' => 'lexer/PhutilTypeLexer.php', 'PhutilTypeMissingParametersException' => 'parser/exception/PhutilTypeMissingParametersException.php', 'PhutilTypeSpec' => 'parser/PhutilTypeSpec.php', 'PhutilTypeSpecTestCase' => 'parser/__tests__/PhutilTypeSpecTestCase.php', 'PhutilURI' => 'parser/PhutilURI.php', 'PhutilURITestCase' => 'parser/__tests__/PhutilURITestCase.php', 'PhutilUSEnglishLocale' => 'internationalization/locales/PhutilUSEnglishLocale.php', 'PhutilUTF8StringTruncator' => 'utils/PhutilUTF8StringTruncator.php', 'PhutilUTF8TestCase' => 'utils/__tests__/PhutilUTF8TestCase.php', 'PhutilUnknownSymbolParserGeneratorException' => 'parser/generator/exception/PhutilUnknownSymbolParserGeneratorException.php', 'PhutilUnreachableRuleParserGeneratorException' => 'parser/generator/exception/PhutilUnreachableRuleParserGeneratorException.php', 'PhutilUnreachableTerminalParserGeneratorException' => 'parser/generator/exception/PhutilUnreachableTerminalParserGeneratorException.php', 'PhutilUrisprintfTestCase' => 'xsprintf/__tests__/PhutilUrisprintfTestCase.php', 'PhutilUtilsTestCase' => 'utils/__tests__/PhutilUtilsTestCase.php', 'PhutilVeryWowEnglishLocale' => 'internationalization/locales/PhutilVeryWowEnglishLocale.php', 'PhutilWordPressAuthAdapter' => 'auth/PhutilWordPressAuthAdapter.php', 'PhutilWordPressFuture' => 'future/wordpress/PhutilWordPressFuture.php', 'PhutilXHPASTBinary' => 'parser/xhpast/bin/PhutilXHPASTBinary.php', 'PhutilXHPASTSyntaxHighlighter' => 'markup/syntax/highlighter/PhutilXHPASTSyntaxHighlighter.php', 'PhutilXHPASTSyntaxHighlighterFuture' => 'markup/syntax/highlighter/xhpast/PhutilXHPASTSyntaxHighlighterFuture.php', 'PhutilXHPASTSyntaxHighlighterTestCase' => 'markup/syntax/highlighter/__tests__/PhutilXHPASTSyntaxHighlighterTestCase.php', 'QueryFuture' => 'future/query/QueryFuture.php', 'TempFile' => 'filesystem/TempFile.php', 'TestAbstractDirectedGraph' => 'utils/__tests__/TestAbstractDirectedGraph.php', 'XHPASTNode' => 'parser/xhpast/api/XHPASTNode.php', 'XHPASTNodeTestCase' => 'parser/xhpast/api/__tests__/XHPASTNodeTestCase.php', 'XHPASTSyntaxErrorException' => 'parser/xhpast/api/XHPASTSyntaxErrorException.php', 'XHPASTToken' => 'parser/xhpast/api/XHPASTToken.php', 'XHPASTTree' => 'parser/xhpast/api/XHPASTTree.php', 'XHPASTTreeTestCase' => 'parser/xhpast/api/__tests__/XHPASTTreeTestCase.php', 'XsprintfUnknownConversionException' => 'xsprintf/exception/XsprintfUnknownConversionException.php', ), 'function' => array( 'array_fuse' => 'utils/utils.php', 'array_interleave' => 'utils/utils.php', 'array_mergev' => 'utils/utils.php', 'array_select_keys' => 'utils/utils.php', 'assert_instances_of' => 'utils/utils.php', 'assert_stringlike' => 'utils/utils.php', 'coalesce' => 'utils/utils.php', 'csprintf' => 'xsprintf/csprintf.php', 'exec_manual' => 'future/exec/execx.php', 'execx' => 'future/exec/execx.php', 'head' => 'utils/utils.php', 'head_key' => 'utils/utils.php', 'hgsprintf' => 'xsprintf/hgsprintf.php', 'hsprintf' => 'markup/render.php', 'id' => 'utils/utils.php', 'idx' => 'utils/utils.php', 'idxv' => 'utils/utils.php', 'ifilter' => 'utils/utils.php', 'igroup' => 'utils/utils.php', 'ipull' => 'utils/utils.php', 'isort' => 'utils/utils.php', 'jsprintf' => 'xsprintf/jsprintf.php', 'last' => 'utils/utils.php', 'last_key' => 'utils/utils.php', 'ldap_sprintf' => 'xsprintf/ldapsprintf.php', 'mfilter' => 'utils/utils.php', 'mgroup' => 'utils/utils.php', 'mpull' => 'utils/utils.php', 'msort' => 'utils/utils.php', 'msortv' => 'utils/utils.php', 'newv' => 'utils/utils.php', 'nonempty' => 'utils/utils.php', 'phlog' => 'error/phlog.php', 'pht' => 'internationalization/pht.php', 'phutil_censor_credentials' => 'utils/utils.php', 'phutil_console_confirm' => 'console/format.php', 'phutil_console_format' => 'console/format.php', 'phutil_console_get_terminal_width' => 'console/format.php', 'phutil_console_prompt' => 'console/format.php', 'phutil_console_require_tty' => 'console/format.php', 'phutil_console_wrap' => 'console/format.php', 'phutil_count' => 'internationalization/pht.php', 'phutil_date_format' => 'utils/viewutils.php', 'phutil_deprecated' => 'moduleutils/moduleutils.php', 'phutil_error_listener_example' => 'error/phlog.php', 'phutil_escape_html' => 'markup/render.php', 'phutil_escape_html_newlines' => 'markup/render.php', 'phutil_escape_uri' => 'markup/render.php', 'phutil_escape_uri_path_component' => 'markup/render.php', 'phutil_fnmatch' => 'utils/utils.php', 'phutil_format_bytes' => 'utils/viewutils.php', 'phutil_format_relative_time' => 'utils/viewutils.php', 'phutil_format_relative_time_detailed' => 'utils/viewutils.php', 'phutil_format_units_generic' => 'utils/viewutils.php', 'phutil_fwrite_nonblocking_stream' => 'utils/utils.php', 'phutil_get_current_library_name' => 'moduleutils/moduleutils.php', 'phutil_get_library_name_for_root' => 'moduleutils/moduleutils.php', 'phutil_get_library_root' => 'moduleutils/moduleutils.php', 'phutil_get_library_root_for_path' => 'moduleutils/moduleutils.php', 'phutil_get_signal_name' => 'future/exec/execx.php', 'phutil_hashes_are_identical' => 'utils/utils.php', 'phutil_implode_html' => 'markup/render.php', 'phutil_ini_decode' => 'utils/utils.php', 'phutil_is_hiphop_runtime' => 'utils/utils.php', 'phutil_is_utf8' => 'utils/utf8.php', 'phutil_is_utf8_slowly' => 'utils/utf8.php', 'phutil_is_utf8_with_only_bmp_characters' => 'utils/utf8.php', 'phutil_is_windows' => 'utils/utils.php', 'phutil_json_decode' => 'utils/utils.php', 'phutil_json_encode' => 'utils/utils.php', 'phutil_load_library' => 'moduleutils/core.php', 'phutil_loggable_string' => 'utils/utils.php', 'phutil_parse_bytes' => 'utils/viewutils.php', 'phutil_passthru' => 'future/exec/execx.php', 'phutil_register_library' => 'moduleutils/core.php', 'phutil_register_library_map' => 'moduleutils/core.php', 'phutil_safe_html' => 'markup/render.php', 'phutil_split_lines' => 'utils/utils.php', 'phutil_tag' => 'markup/render.php', 'phutil_tag_div' => 'markup/render.php', 'phutil_unescape_uri_path_component' => 'markup/render.php', 'phutil_units' => 'utils/utils.php', 'phutil_utf8_console_strlen' => 'utils/utf8.php', 'phutil_utf8_convert' => 'utils/utf8.php', 'phutil_utf8_encode_codepoint' => 'utils/utf8.php', 'phutil_utf8_hard_wrap' => 'utils/utf8.php', 'phutil_utf8_hard_wrap_html' => 'utils/utf8.php', 'phutil_utf8_is_combining_character' => 'utils/utf8.php', 'phutil_utf8_strlen' => 'utils/utf8.php', 'phutil_utf8_strtolower' => 'utils/utf8.php', 'phutil_utf8_strtoupper' => 'utils/utf8.php', 'phutil_utf8_strtr' => 'utils/utf8.php', 'phutil_utf8_ucwords' => 'utils/utf8.php', 'phutil_utf8ize' => 'utils/utf8.php', 'phutil_utf8v' => 'utils/utf8.php', 'phutil_utf8v_codepoints' => 'utils/utf8.php', 'phutil_utf8v_combine_characters' => 'utils/utf8.php', 'phutil_utf8v_combined' => 'utils/utf8.php', 'phutil_validate_json' => 'utils/utils.php', 'phutil_var_export' => 'utils/utils.php', 'ppull' => 'utils/utils.php', 'pregsprintf' => 'xsprintf/pregsprintf.php', 'qsprintf' => 'xsprintf/qsprintf.php', 'qsprintf_check_scalar_type' => 'xsprintf/qsprintf.php', 'qsprintf_check_type' => 'xsprintf/qsprintf.php', 'queryfx' => 'xsprintf/queryfx.php', 'queryfx_all' => 'xsprintf/queryfx.php', 'queryfx_one' => 'xsprintf/queryfx.php', 'tsprintf' => 'xsprintf/tsprintf.php', 'urisprintf' => 'xsprintf/urisprintf.php', 'vcsprintf' => 'xsprintf/csprintf.php', 'vjsprintf' => 'xsprintf/jsprintf.php', 'vqsprintf' => 'xsprintf/qsprintf.php', 'vurisprintf' => 'xsprintf/urisprintf.php', 'xhp_parser_node_constants' => 'parser/xhpast/parser_nodes.php', 'xhpast_parser_token_constants' => 'parser/xhpast/parser_tokens.php', 'xsprintf' => 'xsprintf/xsprintf.php', 'xsprintf_callback_example' => 'xsprintf/xsprintf.php', 'xsprintf_command' => 'xsprintf/csprintf.php', 'xsprintf_javascript' => 'xsprintf/jsprintf.php', 'xsprintf_ldap' => 'xsprintf/ldapsprintf.php', 'xsprintf_mercurial' => 'xsprintf/hgsprintf.php', 'xsprintf_query' => 'xsprintf/qsprintf.php', 'xsprintf_regex' => 'xsprintf/pregsprintf.php', 'xsprintf_terminal' => 'xsprintf/tsprintf.php', 'xsprintf_uri' => 'xsprintf/urisprintf.php', ), 'xmap' => array( 'AASTNode' => 'Phobject', 'AASTNodeList' => array( 'Phobject', 'Countable', 'Iterator', ), 'AASTToken' => 'Phobject', 'AASTTree' => 'Phobject', 'AbstractDirectedGraph' => 'Phobject', 'AbstractDirectedGraphTestCase' => 'PhutilTestCase', 'AphrontAccessDeniedQueryException' => 'AphrontQueryException', 'AphrontBaseMySQLDatabaseConnection' => 'AphrontDatabaseConnection', 'AphrontCharacterSetQueryException' => 'AphrontQueryException', 'AphrontConnectionLostQueryException' => 'AphrontRecoverableQueryException', 'AphrontConnectionQueryException' => 'AphrontQueryException', 'AphrontCountQueryException' => 'AphrontQueryException', 'AphrontDatabaseConnection' => array( 'Phobject', 'PhutilQsprintfInterface', ), 'AphrontDatabaseTransactionState' => 'Phobject', 'AphrontDeadlockQueryException' => 'AphrontRecoverableQueryException', 'AphrontDuplicateKeyQueryException' => 'AphrontQueryException', 'AphrontInvalidCredentialsQueryException' => 'AphrontQueryException', 'AphrontIsolatedDatabaseConnection' => 'AphrontDatabaseConnection', 'AphrontLockTimeoutQueryException' => 'AphrontRecoverableQueryException', 'AphrontMySQLDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection', 'AphrontMySQLiDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection', 'AphrontNotSupportedQueryException' => 'AphrontQueryException', 'AphrontObjectMissingQueryException' => 'AphrontQueryException', 'AphrontParameterQueryException' => 'AphrontQueryException', 'AphrontQueryException' => 'Exception', 'AphrontQueryTimeoutQueryException' => 'AphrontRecoverableQueryException', 'AphrontRecoverableQueryException' => 'AphrontQueryException', 'AphrontRequestStream' => 'Phobject', 'AphrontSchemaQueryException' => 'AphrontQueryException', 'AphrontScopedUnguardedWriteCapability' => 'Phobject', 'AphrontWriteGuard' => 'Phobject', 'BaseHTTPFuture' => 'Future', 'CaseInsensitiveArray' => 'PhutilArray', 'CaseInsensitiveArrayTestCase' => 'PhutilTestCase', 'CommandException' => 'Exception', 'ConduitClient' => 'Phobject', 'ConduitClientException' => 'Exception', 'ConduitClientTestCase' => 'PhutilTestCase', 'ConduitFuture' => 'FutureProxy', 'ExecFuture' => 'PhutilExecutableFuture', 'ExecFutureTestCase' => 'PhutilTestCase', 'ExecPassthruTestCase' => 'PhutilTestCase', 'FileFinder' => 'Phobject', 'FileFinderTestCase' => 'PhutilTestCase', 'FileList' => 'Phobject', 'Filesystem' => 'Phobject', 'FilesystemException' => 'Exception', 'FilesystemTestCase' => 'PhutilTestCase', 'Future' => 'Phobject', 'FutureIterator' => array( 'Phobject', 'Iterator', ), 'FutureIteratorTestCase' => 'PhutilTestCase', 'FutureProxy' => 'Future', 'HTTPFuture' => 'BaseHTTPFuture', 'HTTPFutureCURLResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPFutureCertificateResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPFutureHTTPResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPFutureParseResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPFutureResponseStatus' => 'Exception', 'HTTPFutureTransportResponseStatus' => 'HTTPFutureResponseStatus', 'HTTPSFuture' => 'BaseHTTPFuture', 'ImmediateFuture' => 'Future', 'LibphutilUSEnglishTranslation' => 'PhutilTranslation', 'LinesOfALarge' => array( 'Phobject', 'Iterator', ), 'LinesOfALargeExecFuture' => 'LinesOfALarge', 'LinesOfALargeExecFutureTestCase' => 'PhutilTestCase', 'LinesOfALargeFile' => 'LinesOfALarge', 'LinesOfALargeFileTestCase' => 'PhutilTestCase', 'MFilterTestHelper' => 'Phobject', 'PHPASTParserTestCase' => 'PhutilTestCase', 'PhageAgentBootloader' => 'Phobject', 'PhageAgentTestCase' => 'PhutilTestCase', 'PhagePHPAgent' => 'Phobject', 'PhagePHPAgentBootloader' => 'PhageAgentBootloader', 'Phobject' => 'Iterator', 'PhobjectTestCase' => 'PhutilTestCase', 'PhutilAPCKeyValueCache' => 'PhutilKeyValueCache', 'PhutilAWSEC2Future' => 'PhutilAWSFuture', 'PhutilAWSException' => 'Exception', 'PhutilAWSFuture' => 'FutureProxy', 'PhutilAWSManagementWorkflow' => 'PhutilArgumentWorkflow', 'PhutilAWSS3DeleteManagementWorkflow' => 'PhutilAWSS3ManagementWorkflow', 'PhutilAWSS3Future' => 'PhutilAWSFuture', 'PhutilAWSS3GetManagementWorkflow' => 'PhutilAWSS3ManagementWorkflow', 'PhutilAWSS3ManagementWorkflow' => 'PhutilAWSManagementWorkflow', 'PhutilAWSS3PutManagementWorkflow' => 'PhutilAWSS3ManagementWorkflow', 'PhutilAWSv4Signature' => 'Phobject', 'PhutilAWSv4SignatureTestCase' => 'PhutilTestCase', 'PhutilAggregateException' => 'Exception', 'PhutilAllCapsEnglishLocale' => 'PhutilLocale', 'PhutilAmazonAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilArgumentParser' => 'Phobject', 'PhutilArgumentParserException' => 'Exception', 'PhutilArgumentParserTestCase' => 'PhutilTestCase', 'PhutilArgumentSpecification' => 'Phobject', 'PhutilArgumentSpecificationException' => 'PhutilArgumentParserException', 'PhutilArgumentSpecificationTestCase' => 'PhutilTestCase', 'PhutilArgumentSpellingCorrector' => 'Phobject', 'PhutilArgumentSpellingCorrectorTestCase' => 'PhutilTestCase', 'PhutilArgumentUsageException' => 'PhutilArgumentParserException', 'PhutilArgumentWorkflow' => 'Phobject', 'PhutilArray' => array( 'Phobject', 'Countable', 'ArrayAccess', 'Iterator', ), 'PhutilArrayTestCase' => 'PhutilTestCase', 'PhutilArrayWithDefaultValue' => 'PhutilArray', 'PhutilAsanaAuthAdapter' => 'PhutilOAuthAuthAdapter', 'PhutilAsanaFuture' => 'FutureProxy', 'PhutilAuthAdapter' => 'Phobject', 'PhutilAuthConfigurationException' => 'PhutilAuthException', 'PhutilAuthCredentialException' => 'PhutilAuthException', 'PhutilAuthException' => 'Exception', 'PhutilAuthUserAbortedException' => 'PhutilAuthException', + 'PhutilBacktraceSignalHandler' => 'PhutilSignalHandler', 'PhutilBallOfPHP' => 'Phobject', 'PhutilBitbucketAuthAdapter' => 'PhutilOAuth1AuthAdapter', 'PhutilBootloaderException' => 'Exception', 'PhutilBritishEnglishLocale' => 'PhutilLocale', 'PhutilBufferedIterator' => array( 'Phobject', 'Iterator', ), 'PhutilBufferedIteratorTestCase' => 'PhutilTestCase', 'PhutilBugtraqParser' => 'Phobject', 'PhutilBugtraqParserTestCase' => 'PhutilTestCase', 'PhutilCIDRBlock' => 'Phobject', 'PhutilCIDRList' => 'Phobject', 'PhutilCLikeCodeSnippetContextFreeGrammar' => 'PhutilCodeSnippetContextFreeGrammar', + 'PhutilCalendarAbsoluteDateTime' => 'PhutilCalendarDateTime', + 'PhutilCalendarContainerNode' => 'PhutilCalendarNode', + 'PhutilCalendarDateTime' => 'Phobject', + 'PhutilCalendarDocumentNode' => 'PhutilCalendarContainerNode', + 'PhutilCalendarDuration' => 'Phobject', + 'PhutilCalendarEventNode' => 'PhutilCalendarNode', + 'PhutilCalendarNode' => 'Phobject', + 'PhutilCalendarProxyDateTime' => 'PhutilCalendarDateTime', + 'PhutilCalendarRawNode' => 'PhutilCalendarContainerNode', + '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/auth/PhutilSlackAuthAdapter.php b/src/auth/PhutilSlackAuthAdapter.php new file mode 100644 index 0000000..6578a9a --- /dev/null +++ b/src/auth/PhutilSlackAuthAdapter.php @@ -0,0 +1,61 @@ +getOAuthAccountData('user'); + return idx($user, 'id'); + } + + public function getAccountEmail() { + $user = $this->getOAuthAccountData('user'); + return idx($user, 'email'); + } + + public function getAccountImageURI() { + $user = $this->getOAuthAccountData('user'); + return idx($user, 'image_512'); + } + + public function getAccountRealName() { + $user = $this->getOAuthAccountData('user'); + return idx($user, 'name'); + } + + protected function getAuthenticateBaseURI() { + return 'https://slack.com/oauth/authorize'; + } + + protected function getTokenBaseURI() { + return 'https://slack.com/api/oauth.access'; + } + + public function getScope() { + return 'identity.basic,identity.team,identity.avatar'; + } + + public function getExtraAuthenticateParameters() { + return array( + 'response_type' => 'code', + ); + } + + protected function loadOAuthAccountData() { + return id(new PhutilSlackFuture()) + ->setAccessToken($this->getAccessToken()) + ->setRawSlackQuery('users.identity') + ->resolve(); + } + +} diff --git a/src/daemon/PhutilDaemon.php b/src/daemon/PhutilDaemon.php index 43a6c3f..629a567 100644 --- a/src/daemon/PhutilDaemon.php +++ b/src/daemon/PhutilDaemon.php @@ -1,410 +1,407 @@ shouldExit()) { * if (work_available()) { * $this->willBeginWork(); * do_work(); * $this->sleep(0); * } else { * $this->willBeginIdle(); * $this->sleep(1); * } * } * * In particular, call @{method:willBeginWork} before becoming busy, and * @{method:willBeginIdle} when no work is available. If the daemon is launched * into an autoscale pool, this will cause the pool to automatically scale up * when busy and down when idle. * * See @{class:PhutilHighIntensityIntervalDaemon} for an example of a simple * autoscaling daemon. * * Launching a daemon which does not make these callbacks into an autoscale * pool will have no effect. * * @task overseer Communicating With the Overseer * @task autoscale Autoscaling Daemon Pools */ abstract class PhutilDaemon extends Phobject { const MESSAGETYPE_STDOUT = 'stdout'; const MESSAGETYPE_HEARTBEAT = 'heartbeat'; const MESSAGETYPE_BUSY = 'busy'; const MESSAGETYPE_IDLE = 'idle'; const MESSAGETYPE_DOWN = 'down'; const WORKSTATE_BUSY = 'busy'; const WORKSTATE_IDLE = 'idle'; private $argv; private $traceMode; private $traceMemory; private $verbose; private $notifyReceived; private $inGracefulShutdown; private $workState = null; private $idleSince = null; private $autoscaleProperties = array(); final public function setVerbose($verbose) { $this->verbose = $verbose; return $this; } final public function getVerbose() { return $this->verbose; } - private static $sighandlerInstalled; - final public function __construct(array $argv) { $this->argv = $argv; - if (!self::$sighandlerInstalled) { - self::$sighandlerInstalled = true; - pcntl_signal(SIGTERM, __CLASS__.'::exitOnSignal'); + $router = PhutilSignalRouter::getRouter(); + $handler_key = 'daemon.term'; + if (!$router->getHandler($handler_key)) { + $handler = new PhutilCallbackSignalHandler( + SIGTERM, + __CLASS__.'::onTermSignal'); + $router->installHandler($handler_key, $handler); } pcntl_signal(SIGINT, array($this, 'onGracefulSignal')); pcntl_signal(SIGUSR2, array($this, 'onNotifySignal')); // Without discard mode, this consumes unbounded amounts of memory. Keep // memory bounded. PhutilServiceProfiler::getInstance()->enableDiscardMode(); $this->beginStdoutCapture(); } final public function __destruct() { $this->endStdoutCapture(); } final public function stillWorking() { $this->emitOverseerMessage(self::MESSAGETYPE_HEARTBEAT, null); if ($this->traceMemory) { $daemon = get_class($this); fprintf( STDERR, "%s %s %s\n", '', $daemon, pht( 'Memory Usage: %s KB', new PhutilNumber(memory_get_usage() / 1024, 1))); } } final public function shouldExit() { return $this->inGracefulShutdown; } final protected function sleep($duration) { $this->notifyReceived = false; $this->willSleep($duration); $this->stillWorking(); $is_autoscale = $this->isClonedAutoscaleDaemon(); $scale_down = $this->getAutoscaleDownDuration(); $max_sleep = 60; if ($is_autoscale) { $max_sleep = min($max_sleep, $scale_down); } if ($is_autoscale) { if ($this->workState == self::WORKSTATE_IDLE) { $dur = (time() - $this->idleSince); $this->log(pht('Idle for %s seconds.', $dur)); } } while ($duration > 0 && !$this->notifyReceived && !$this->shouldExit()) { // If this is an autoscaling clone and we've been idle for too long, // we're going to scale the pool down by exiting and not restarting. The // DOWN message tells the overseer that we don't want to be restarted. if ($is_autoscale) { if ($this->workState == self::WORKSTATE_IDLE) { if ($this->idleSince && ($this->idleSince + $scale_down < time())) { $this->inGracefulShutdown = true; $this->emitOverseerMessage(self::MESSAGETYPE_DOWN, null); $this->log( pht( 'Daemon was idle for more than %s second(s), '. 'scaling pool down.', new PhutilNumber($scale_down))); break; } } } sleep(min($duration, $max_sleep)); $duration -= $max_sleep; $this->stillWorking(); } } protected function willSleep($duration) { return; } - public static function exitOnSignal($signo) { + public static function onTermSignal($signo) { self::didCatchSignal($signo); - - // Normally, PHP doesn't invoke destructors when exiting in response to - // a signal. This forces it to do so, so we have a fighting chance of - // releasing any locks, leases or resources on our way out. - exit(128 + $signo); } final protected function getArgv() { return $this->argv; } final public function execute() { $this->willRun(); $this->run(); } abstract protected function run(); final public function setTraceMemory() { $this->traceMemory = true; return $this; } final public function getTraceMemory() { return $this->traceMemory; } final public function setTraceMode() { $this->traceMode = true; PhutilServiceProfiler::installEchoListener(); PhutilConsole::getConsole()->getServer()->setEnableLog(true); $this->didSetTraceMode(); return $this; } final public function getTraceMode() { return $this->traceMode; } final public function onGracefulSignal($signo) { self::didCatchSignal($signo); $this->inGracefulShutdown = true; } final public function onNotifySignal($signo) { self::didCatchSignal($signo); $this->notifyReceived = true; $this->onNotify($signo); } protected function onNotify($signo) { // This is a hook for subclasses. } protected function willRun() { // This is a hook for subclasses. } protected function didSetTraceMode() { // This is a hook for subclasses. } final protected function log($message) { if ($this->verbose) { $daemon = get_class($this); fprintf(STDERR, "%s %s %s\n", '', $daemon, $message); } } private static function didCatchSignal($signo) { $signame = phutil_get_signal_name($signo); fprintf( STDERR, "%s Caught signal %s (%s).\n", '', $signo, $signame); } /* -( Communicating With the Overseer )------------------------------------ */ private function beginStdoutCapture() { ob_start(array($this, 'didReceiveStdout'), 2); } private function endStdoutCapture() { ob_end_flush(); } public function didReceiveStdout($data) { if (!strlen($data)) { return ''; } return $this->encodeOverseerMessage(self::MESSAGETYPE_STDOUT, $data); } private function encodeOverseerMessage($type, $data) { $structure = array($type); if ($data !== null) { $structure[] = $data; } return json_encode($structure)."\n"; } private function emitOverseerMessage($type, $data) { $this->endStdoutCapture(); echo $this->encodeOverseerMessage($type, $data); $this->beginStdoutCapture(); } public static function errorListener($event, $value, array $metadata) { // If the caller has redirected the error log to a file, PHP won't output // messages to stderr, so the overseer can't capture them. Install a // listener which just echoes errors to stderr, so the overseer is always // aware of errors. $console = PhutilConsole::getConsole(); $message = idx($metadata, 'default_message'); if ($message) { $console->writeErr("%s\n", $message); } if (idx($metadata, 'trace')) { $trace = PhutilErrorHandler::formatStacktrace($metadata['trace']); $console->writeErr("%s\n", $trace); } } /* -( Autoscaling )-------------------------------------------------------- */ /** * Prepare to become busy. This may autoscale the pool up. * * This notifies the overseer that the daemon has become busy. If daemons * that are part of an autoscale pool are continuously busy for a prolonged * period of time, the overseer may scale up the pool. * * @return this * @task autoscale */ protected function willBeginWork() { if ($this->workState != self::WORKSTATE_BUSY) { $this->workState = self::WORKSTATE_BUSY; $this->idleSince = null; $this->emitOverseerMessage(self::MESSAGETYPE_BUSY, null); } return $this; } /** * Prepare to idle. This may autoscale the pool down. * * This notifies the overseer that the daemon is no longer busy. If daemons * that are part of an autoscale pool are idle for a prolonged period of time, * they may exit to scale the pool down. * * @return this * @task autoscale */ protected function willBeginIdle() { if ($this->workState != self::WORKSTATE_IDLE) { $this->workState = self::WORKSTATE_IDLE; $this->idleSince = time(); $this->emitOverseerMessage(self::MESSAGETYPE_IDLE, null); } return $this; } /** * Determine if this is a clone or the original daemon. * * @return bool True if this is an cloned autoscaling daemon. * @task autoscale */ private function isClonedAutoscaleDaemon() { return (bool)$this->getAutoscaleProperty('clone', false); } /** * Get the duration (in seconds) which a daemon must be continuously idle * for before it should exit to scale the pool down. * * @return int Duration, in seconds. * @task autoscale */ private function getAutoscaleDownDuration() { return $this->getAutoscaleProperty('down', 15); } /** * Configure autoscaling for this daemon. * * @param map Map of autoscale properties. * @return this * @task autoscale */ public function setAutoscaleProperties(array $autoscale_properties) { PhutilTypeSpec::checkMap( $autoscale_properties, array( 'group' => 'optional string', 'up' => 'optional int', 'down' => 'optional int', 'pool' => 'optional int', 'clone' => 'optional bool', 'reserve' => 'optional int|float', )); $this->autoscaleProperties = $autoscale_properties; return $this; } /** * Read autoscaling configuration for this daemon. * * @param string Property to read. * @param wild Default value to return if the property is not set. * @return wild Property value, or `$default` if one is not set. * @task autoscale */ private function getAutoscaleProperty($key, $default = null) { return idx($this->autoscaleProperties, $key, $default); } } diff --git a/src/future/exec/ExecFuture.php b/src/future/exec/ExecFuture.php index 11ecca1..f515e9a 100644 --- a/src/future/exec/ExecFuture.php +++ b/src/future/exec/ExecFuture.php @@ -1,915 +1,962 @@ array('pipe', 'r'), // stdin 1 => array('pipe', 'w'), // stdout 2 => array('pipe', 'w'), // stderr ); /* -( Creating ExecFutures )----------------------------------------------- */ /** * Create a new ExecFuture. * * $future = new ExecFuture('wc -l %s', $file_path); * * @param string `sprintf()`-style command string which will be passed * through @{function:csprintf} with the rest of the arguments. * @param ... Zero or more additional arguments for @{function:csprintf}. * @return ExecFuture ExecFuture for running the specified command. * @task create */ public function __construct($command) { $argv = func_get_args(); $this->command = call_user_func_array('csprintf', $argv); $this->stdin = new PhutilRope(); } /* -( Command Information )------------------------------------------------ */ /** * Retrieve the raw command to be executed. * * @return string Raw command. * @task info */ public function getCommand() { return $this->command; } /** * Retrieve the byte limit for the stderr buffer. * * @return int Maximum buffer size, in bytes. * @task info */ public function getStderrSizeLimit() { return $this->stderrSizeLimit; } /** * Retrieve the byte limit for the stdout buffer. * * @return int Maximum buffer size, in bytes. * @task info */ public function getStdoutSizeLimit() { return $this->stdoutSizeLimit; } /** * Get the process's pid. This only works after execution is initiated, e.g. * by a call to start(). * * @return int Process ID of the executing process. * @task info */ public function getPID() { $status = $this->procGetStatus(); return $status['pid']; } /* -( Configuring Execution )---------------------------------------------- */ /** * Set a maximum size for the stdout read buffer. To limit stderr, see * @{method:setStderrSizeLimit}. The major use of these methods is to use less * memory if you are running a command which sometimes produces huge volumes * of output that you don't really care about. * * NOTE: Setting this to 0 means "no buffer", not "unlimited buffer". * * @param int Maximum size of the stdout read buffer. * @return this * @task config */ public function setStdoutSizeLimit($limit) { $this->stdoutSizeLimit = $limit; return $this; } /** * Set a maximum size for the stderr read buffer. * See @{method:setStdoutSizeLimit} for discussion. * * @param int Maximum size of the stderr read buffer. * @return this * @task config */ public function setStderrSizeLimit($limit) { $this->stderrSizeLimit = $limit; return $this; } /** * Set the maximum internal read buffer size this future. The future will * block reads once the internal stdout or stderr buffer exceeds this size. * * NOTE: If you @{method:resolve} a future with a read buffer limit, you may * block forever! * * TODO: We should probably release the read buffer limit during * @{method:resolve}, or otherwise detect this. For now, be careful. * * @param int|null Maximum buffer size, or `null` for unlimited. * @return this */ public function setReadBufferSize($read_buffer_size) { $this->readBufferSize = $read_buffer_size; return $this; } /** * Set whether to use non-blocking streams on Windows. * * @param bool Whether to use non-blocking streams. * @return this * @task config */ public function setUseWindowsFileStreams($use_streams) { if (phutil_is_windows()) { $this->useWindowsFileStreams = $use_streams; } return $this; } /* -( Interacting With Commands )------------------------------------------ */ /** * Read and return output from stdout and stderr, if any is available. This * method keeps a read cursor on each stream, but the entire streams are * still returned when the future resolves. You can call read() again after * resolving the future to retrieve only the parts of the streams you did not * previously read: * * $future = new ExecFuture('...'); * // ... * list($stdout) = $future->read(); // Returns output so far * list($stdout) = $future->read(); // Returns new output since first call * // ... * list($stdout) = $future->resolvex(); // Returns ALL output * list($stdout) = $future->read(); // Returns unread output * * NOTE: If you set a limit with @{method:setStdoutSizeLimit} or * @{method:setStderrSizeLimit}, this method will not be able to read data * past the limit. * * NOTE: If you call @{method:discardBuffers}, all the stdout/stderr data * will be thrown away and the cursors will be reset. * * @return pair <$stdout, $stderr> pair with new output since the last call * to this method. * @task interact */ public function read() { $stdout = $this->readStdout(); $result = array( $stdout, (string)substr($this->stderr, $this->stderrPos), ); $this->stderrPos = strlen($this->stderr); return $result; } public function readStdout() { if ($this->start) { $this->isReady(); // Sync } $result = (string)substr($this->stdout, $this->stdoutPos); $this->stdoutPos = strlen($this->stdout); return $result; } /** * Write data to stdin of the command. * * @param string Data to write. * @param bool If true, keep the pipe open for writing. By default, the pipe * will be closed as soon as possible so that commands which * listen for EOF will execute. If you want to keep the pipe open * past the start of command execution, do an empty write with * `$keep_pipe = true` first. * @return this * @task interact */ public function write($data, $keep_pipe = false) { if (strlen($data)) { if (!$this->stdin) { throw new Exception(pht('Writing to a closed pipe!')); } $this->stdin->append($data); } $this->closePipe = !$keep_pipe; return $this; } /** * Permanently discard the stdout and stderr buffers and reset the read * cursors. This is basically useful only if you are streaming a large amount * of data from some process: * * $future = new ExecFuture('zcat huge_file.gz'); * do { * $done = $future->resolve(0.1); // Every 100ms, * list($stdout) = $future->read(); // read output... * echo $stdout; // send it somewhere... * $future->discardBuffers(); // and then free the buffers. * } while ($done === null); * * Conceivably you might also need to do this if you're writing a client using * @{class:ExecFuture} and `netcat`, but you probably should not do that. * * NOTE: This completely discards the data. It won't be available when the * future resolves. This is almost certainly only useful if you need the * buffer memory for some reason. * * @return this * @task interact */ public function discardBuffers() { $this->discardStdoutBuffer(); $this->stderr = ''; $this->stderrPos = 0; return $this; } public function discardStdoutBuffer() { $this->stdout = ''; $this->stdoutPos = 0; return $this; } /** * Returns true if this future was killed by a timeout configured with * @{method:setTimeout}. * * @return bool True if the future was killed for exceeding its time limit. */ public function getWasKilledByTimeout() { return $this->killedByTimeout; } /* -( Configuring Execution )---------------------------------------------- */ /** * Set a hard limit on execution time. If the command runs longer, it will - * be killed and the future will resolve with an error code. You can test + * be terminated and the future will resolve with an error code. You can test * if a future was killed by a timeout with @{method:getWasKilledByTimeout}. * - * @param int Maximum number of seconds this command may execute for. + * The subprocess will be sent a `TERM` signal, and then a `KILL` signal a + * short while later if it fails to exit. + * + * @param int Maximum number of seconds this command may execute for before + * it is signaled. * @return this * @task config */ public function setTimeout($seconds) { - $this->timeout = $seconds; + $this->terminateTimeout = $seconds; + $this->killTimeout = $seconds + min($seconds, 60); return $this; } /* -( Resolving Execution )------------------------------------------------ */ /** * Resolve a command you expect to exit with return code 0. Works like * @{method:resolve}, but throws if $err is nonempty. Returns only * $stdout and $stderr. See also @{function:execx}. * * list($stdout, $stderr) = $future->resolvex(); * * @param float Optional timeout after which resolution will pause and * execution will return to the caller. * @return pair <$stdout, $stderr> pair. * @task resolve */ public function resolvex($timeout = null) { list($err, $stdout, $stderr) = $this->resolve($timeout); if ($err) { $cmd = $this->command; throw new CommandException( pht('Command failed with error #%d!', $err), $cmd, $err, $stdout, $stderr); } return array($stdout, $stderr); } /** * Resolve a command you expect to return valid JSON. Works like * @{method:resolvex}, but also throws if stderr is nonempty, or stdout is not * valid JSON. Returns a PHP array, decoded from the JSON command output. * * @param float Optional timeout after which resolution will pause and * execution will return to the caller. * @return array PHP array, decoded from JSON command output. * @task resolve */ public function resolveJSON($timeout = null) { list($stdout, $stderr) = $this->resolvex($timeout); if (strlen($stderr)) { $cmd = $this->command; throw new CommandException( pht( "JSON command '%s' emitted text to stderr when none was expected: %d", $cmd, $stderr), $cmd, 0, $stdout, $stderr); } try { return phutil_json_decode($stdout); } catch (PhutilJSONParserException $ex) { $cmd = $this->command; throw new CommandException( pht( "JSON command '%s' did not produce a valid JSON object on stdout: %s", $cmd, $stdout), $cmd, 0, $stdout, $stderr); } } /** * Resolve the process by abruptly terminating it. * * @return list List of results. * @task resolve */ public function resolveKill() { if (!$this->result) { - if (defined('SIGKILL')) { - $signal = SIGKILL; - } else { - $signal = 9; - } - + $signal = 9; proc_terminate($this->proc, $signal); + $this->result = array( 128 + $signal, $this->stdout, $this->stderr, ); $this->closeProcess(); } return $this->result; } /* -( Internals )---------------------------------------------------------- */ /** * Provides read sockets to the future core. * * @return list List of read sockets. * @task internal */ public function getReadSockets() { list($stdin, $stdout, $stderr) = $this->pipes; $sockets = array(); if (isset($stdout) && !feof($stdout)) { $sockets[] = $stdout; } if (isset($stderr) && !feof($stderr)) { $sockets[] = $stderr; } return $sockets; } /** * Provides write sockets to the future core. * * @return list List of write sockets. * @task internal */ public function getWriteSockets() { list($stdin, $stdout, $stderr) = $this->pipes; $sockets = array(); if (isset($stdin) && $this->stdin->getByteLength() && !feof($stdin)) { $sockets[] = $stdin; } return $sockets; } /** * Determine if the read buffer is empty. * * @return bool True if the read buffer is empty. * @task internal */ public function isReadBufferEmpty() { return !strlen($this->stdout); } /** * Determine if the write buffer is empty. * * @return bool True if the write buffer is empty. * @task internal */ public function isWriteBufferEmpty() { return !$this->getWriteBufferSize(); } /** * Determine the number of bytes in the write buffer. * * @return int Number of bytes in the write buffer. * @task internal */ public function getWriteBufferSize() { if (!$this->stdin) { return 0; } return $this->stdin->getByteLength(); } /** * Reads some bytes from a stream, discarding output once a certain amount * has been accumulated. * * @param resource Stream to read from. * @param int Maximum number of bytes to return from $stream. If * additional bytes are available, they will be read and * discarded. * @param string Human-readable description of stream, for exception * message. * @param int Maximum number of bytes to read. * @return string The data read from the stream. * @task internal */ private function readAndDiscard($stream, $limit, $description, $length) { $output = ''; if ($length <= 0) { return ''; } do { $data = fread($stream, min($length, 64 * 1024)); if (false === $data) { throw new Exception(pht('Failed to read from %s', $description)); } $read_bytes = strlen($data); if ($read_bytes > 0 && $limit > 0) { if ($read_bytes > $limit) { $data = substr($data, 0, $limit); } $output .= $data; $limit -= strlen($data); } if (strlen($output) >= $length) { break; } } while ($read_bytes > 0); return $output; } /** * Begin or continue command execution. * * @return bool True if future has resolved. * @task internal */ public function isReady() { // NOTE: We have soft dependencies on PhutilServiceProfiler and // PhutilErrorTrap here. These dependencies are soft to avoid the need to // build them into the Phage agent. Under normal circumstances, these // classes are always available. if (!$this->pipes) { // NOTE: See note above about Phage. if (class_exists('PhutilServiceProfiler')) { $profiler = PhutilServiceProfiler::getInstance(); $this->profilerCallID = $profiler->beginServiceCall( array( 'type' => 'exec', 'command' => (string)$this->command, )); } if (!$this->start) { // We might already have started the timer via initiating resolution. $this->start = microtime(true); } $unmasked_command = $this->command; if ($unmasked_command instanceof PhutilCommandString) { $unmasked_command = $unmasked_command->getUnmaskedString(); } $pipes = array(); if (phutil_is_windows()) { // See T4395. proc_open under Windows uses "cmd /C [cmd]", which will // strip the first and last quote when there aren't exactly two quotes // (and some other conditions as well). This results in a command that // looks like `command" "path to my file" "something something` which is // clearly wrong. By surrounding the command string with quotes we can // be sure this process is harmless. if (strpos($unmasked_command, '"') !== false) { $unmasked_command = '"'.$unmasked_command.'"'; } } if ($this->hasEnv()) { $env = $this->getEnv(); } else { $env = null; } $cwd = $this->getCWD(); // NOTE: See note above about Phage. if (class_exists('PhutilErrorTrap')) { $trap = new PhutilErrorTrap(); } else { $trap = null; } $spec = self::$descriptorSpec; if ($this->useWindowsFileStreams) { $this->windowsStdoutTempFile = new TempFile(); $this->windowsStderrTempFile = new TempFile(); $spec = array( 0 => self::$descriptorSpec[0], // stdin 1 => fopen($this->windowsStdoutTempFile, 'wb'), // stdout 2 => fopen($this->windowsStderrTempFile, 'wb'), // stderr ); if (!$spec[1] || !$spec[2]) { throw new Exception(pht( 'Unable to create temporary files for '. 'Windows stdout / stderr streams')); } } $proc = @proc_open( $unmasked_command, $spec, $pipes, $cwd, $env); if ($this->useWindowsFileStreams) { fclose($spec[1]); fclose($spec[2]); $pipes = array( 0 => head($pipes), // stdin 1 => fopen($this->windowsStdoutTempFile, 'rb'), // stdout 2 => fopen($this->windowsStderrTempFile, 'rb'), // stderr ); if (!$pipes[1] || !$pipes[2]) { throw new Exception(pht( 'Unable to open temporary files for '. 'reading Windows stdout / stderr streams')); } } if ($trap) { $err = $trap->getErrorsAsString(); $trap->destroy(); } else { $err = error_get_last(); } if (!is_resource($proc)) { throw new Exception( pht( 'Failed to `%s`: %s', 'proc_open()', $err)); } $this->pipes = $pipes; $this->proc = $proc; list($stdin, $stdout, $stderr) = $pipes; if (!phutil_is_windows()) { // On Windows, we redirect process standard output and standard error // through temporary files, and then use stream_select to determine // if there's more data to read. if ((!stream_set_blocking($stdout, false)) || (!stream_set_blocking($stderr, false)) || (!stream_set_blocking($stdin, false))) { $this->__destruct(); throw new Exception(pht('Failed to set streams nonblocking.')); } } $this->tryToCloseStdin(); return false; } if (!$this->proc) { return true; } list($stdin, $stdout, $stderr) = $this->pipes; while (isset($this->stdin) && $this->stdin->getByteLength()) { $write_segment = $this->stdin->getAnyPrefix(); $bytes = fwrite($stdin, $write_segment); if ($bytes === false) { throw new Exception(pht('Unable to write to stdin!')); } else if ($bytes) { $this->stdin->removeBytesFromHead($bytes); } else { // Writes are blocked for now. break; } } $this->tryToCloseStdin(); // Read status before reading pipes so that we can never miss data that // arrives between our last read and the process exiting. $status = $this->procGetStatus(); $read_buffer_size = $this->readBufferSize; $max_stdout_read_bytes = PHP_INT_MAX; $max_stderr_read_bytes = PHP_INT_MAX; if ($read_buffer_size !== null) { $max_stdout_read_bytes = $read_buffer_size - strlen($this->stdout); $max_stderr_read_bytes = $read_buffer_size - strlen($this->stderr); } if ($max_stdout_read_bytes > 0) { $this->stdout .= $this->readAndDiscard( $stdout, $this->getStdoutSizeLimit() - strlen($this->stdout), 'stdout', $max_stdout_read_bytes); } if ($max_stderr_read_bytes > 0) { $this->stderr .= $this->readAndDiscard( $stderr, $this->getStderrSizeLimit() - strlen($this->stderr), 'stderr', $max_stderr_read_bytes); } $is_done = false; if (!$status['running']) { // We may still have unread bytes on stdout or stderr, particularly if // this future is being buffered and streamed. If we do, we don't want to // consider the subprocess to have exited until we've read everything. // See T9724 for context. if (feof($stdout) && feof($stderr)) { $is_done = true; } } if ($is_done) { if ($this->useWindowsFileStreams) { fclose($stdout); fclose($stderr); } // If the subprocess got nuked with `kill -9`, we get a -1 exitcode. // Upgrade this to a slightly more informative value by examining the // terminating signal code. $err = $status['exitcode']; if ($err == -1) { if ($status['signaled']) { $err = 128 + $status['termsig']; } } $this->result = array( $err, $this->stdout, $this->stderr, ); $this->closeProcess(); return true; } $elapsed = (microtime(true) - $this->start); - if ($this->timeout && ($elapsed >= $this->timeout)) { + + if ($this->terminateTimeout && ($elapsed >= $this->terminateTimeout)) { + if (!$this->didTerminate) { + $this->killedByTimeout = true; + $this->sendTerminateSignal(); + return false; + } + } + + if ($this->killTimeout && ($elapsed >= $this->killTimeout)) { $this->killedByTimeout = true; $this->resolveKill(); return true; } } /** * @return void * @task internal */ public function __destruct() { if (!$this->proc) { return; } // NOTE: If we try to proc_close() an open process, we hang indefinitely. To // avoid this, kill the process explicitly if it's still running. $status = $this->procGetStatus(); if ($status['running']) { - $this->resolveKill(); + $this->sendTerminateSignal(); + if (!$this->waitForExit(5)) { + $this->resolveKill(); + } } else { $this->closeProcess(); } } /** * Close and free resources if necessary. * * @return void * @task internal */ private function closeProcess() { foreach ($this->pipes as $pipe) { if (isset($pipe)) { @fclose($pipe); } } $this->pipes = array(null, null, null); if ($this->proc) { @proc_close($this->proc); $this->proc = null; } $this->stdin = null; if ($this->profilerCallID !== null) { $profiler = PhutilServiceProfiler::getInstance(); $profiler->endServiceCall( $this->profilerCallID, array( 'err' => $this->result ? idx($this->result, 0) : null, )); $this->profilerCallID = null; } } /** * Execute `proc_get_status()`, but avoid pitfalls. * * @return dict Process status. * @task internal */ private function procGetStatus() { // After the process exits, we only get one chance to read proc_get_status() // before it starts returning garbage. Make sure we don't throw away the // last good read. if ($this->procStatus) { if (!$this->procStatus['running']) { return $this->procStatus; } } $this->procStatus = proc_get_status($this->proc); return $this->procStatus; } /** * Try to close stdin, if we're done using it. This keeps us from hanging if * the process on the other end of the pipe is waiting for EOF. * * @return void * @task internal */ private function tryToCloseStdin() { if (!$this->closePipe) { // We've been told to keep the pipe open by a call to write(..., true). return; } if ($this->stdin->getByteLength()) { // We still have bytes to write. return; } list($stdin) = $this->pipes; if (!$stdin) { // We've already closed stdin. return; } // There's nothing stopping us from closing stdin, so close it. @fclose($stdin); $this->pipes[0] = null; } public function getDefaultWait() { $wait = parent::getDefaultWait(); - if ($this->timeout) { + $next_timeout = $this->getNextTimeout(); + if ($next_timeout) { if (!$this->start) { $this->start = microtime(true); } $elapsed = (microtime(true) - $this->start); - $wait = max(0, min($this->timeout - $elapsed, $wait)); + $wait = max(0, min($next_timeout - $elapsed, $wait)); } return $wait; } + private function getNextTimeout() { + if ($this->didTerminate) { + return $this->killTimeout; + } else { + return $this->terminateTimeout; + } + } + + private function sendTerminateSignal() { + $this->didTerminate = true; + proc_terminate($this->proc); + return $this; + } + + private function waitForExit($duration) { + $start = microtime(true); + + while (true) { + $status = $this->procGetStatus(); + if (!$status['running']) { + return true; + } + + $waited = (microtime(true) - $start); + if ($waited > $duration) { + return false; + } + } + } + } diff --git a/src/future/exec/PhutilBacktraceSignalHandler.php b/src/future/exec/PhutilBacktraceSignalHandler.php new file mode 100644 index 0000000..131a54b --- /dev/null +++ b/src/future/exec/PhutilBacktraceSignalHandler.php @@ -0,0 +1,22 @@ +getTraceAsString()); + } + +} diff --git a/src/future/exec/PhutilCallbackSignalHandler.php b/src/future/exec/PhutilCallbackSignalHandler.php new file mode 100644 index 0000000..2cde547 --- /dev/null +++ b/src/future/exec/PhutilCallbackSignalHandler.php @@ -0,0 +1,22 @@ +signal = $signal; + $this->callback = $callback; + } + + public function canHandleSignal(PhutilSignalRouter $router, $signo) { + return ($signo === $this->signal); + } + + public function handleSignal(PhutilSignalRouter $router, $signo) { + call_user_func($this->callback, $signo); + } + +} diff --git a/src/future/exec/PhutilSignalHandler.php b/src/future/exec/PhutilSignalHandler.php new file mode 100644 index 0000000..d0ffb9d --- /dev/null +++ b/src/future/exec/PhutilSignalHandler.php @@ -0,0 +1,8 @@ + + } + + public static function initialize() { + if (!self::$router) { + $router = new self(); + + // If pcntl_signal() does not exist (particularly, on Windows), just + // don't install signal handlers. + if (function_exists('pcntl_signal')) { + pcntl_signal(SIGHUP, array($router, 'routeSignal')); + pcntl_signal(SIGTERM, array($router, 'routeSignal')); + } + + self::$router = $router; + } + + return self::getRouter(); + } + + public static function getRouter() { + if (!self::$router) { + throw new Exception(pht('Signal router has not been initialized!')); + } + + return self::$router; + } + + public function installHandler($key, PhutilSignalHandler $handler) { + if (isset($this->handlers[$key])) { + throw new Exception( + pht( + 'Signal handler with key "%s" is already installed.', + $key)); + } + + $this->handlers[$key] = $handler; + + return $this; + } + + public function getHandler($key) { + return idx($this->handlers, $key); + } + + public function routeSignal($signo) { + $exceptions = array(); + + $handlers = $this->handlers; + foreach ($handlers as $key => $handler) { + try { + if ($handler->canHandleSignal($this, $signo)) { + $handler->handleSignal($this, $signo); + } + } catch (Exception $ex) { + $exceptions[] = $ex; + } + } + + if ($exceptions) { + throw new PhutilAggregateException( + pht( + 'Signal handlers raised exceptions while handling "%s".', + phutil_get_signal_name($signo))); + } + + switch ($signo) { + case SIGTERM: + // Normally, PHP doesn't invoke destructors when exiting in response to + // a signal. This forces it to do so, so we have a fighting chance of + // releasing any locks, leases or resources on our way out. + exit(128 + $signo); + } + } + +} diff --git a/src/future/slack/PhutilSlackFuture.php b/src/future/slack/PhutilSlackFuture.php new file mode 100644 index 0000000..0f23290 --- /dev/null +++ b/src/future/slack/PhutilSlackFuture.php @@ -0,0 +1,87 @@ +accessToken = $token; + return $this; + } + + public function setClientID($client_id) { + $this->clientID = $client_id; + return $this; + } + + public function setRawSlackQuery($action, array $params = array()) { + $this->action = $action; + $this->params = $params; + return $this; + } + + public function setMethod($method) { + $this->method = $method; + return $this; + } + + protected function getProxiedFuture() { + if (!$this->future) { + $params = $this->params; + + if (!$this->action) { + throw new Exception(pht('You must %s!', 'setRawSlackQuery()')); + } + + if (!$this->accessToken) { + throw new Exception(pht('You must %s!', 'setAccessToken()')); + } + + $uri = new PhutilURI('https://slack.com/'); + $uri->setPath('/api/'.$this->action); + $uri->setQueryParam('token', $this->accessToken); + + $future = new HTTPSFuture($uri); + $future->setData($this->params); + $future->setMethod($this->method); + + $this->future = $future; + } + + return $this->future; + } + + protected function didReceiveResult($result) { + list($status, $body, $headers) = $result; + + if ($status->isError()) { + throw $status; + } + + $data = null; + try { + $data = phutil_json_decode($body); + } catch (PhutilJSONParserException $ex) { + throw new PhutilProxyException( + pht('Expected JSON response from Slack.'), + $ex); + } + + if (idx($data, 'error')) { + $error = $data['error']; + throw new Exception(pht('Received error from Slack: %s', $error)); + } + + return $data; + } + +} diff --git a/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php b/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php new file mode 100644 index 0000000..e474dcc --- /dev/null +++ b/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php @@ -0,0 +1,178 @@ +\d{4})(?P\d{2})(?P\d{2})'. + '(?:'. + 'T(?P\d{2})(?P\d{2})(?P\d{2})(?Z)?'. + ')?'. + '\z/'; + + $matches = null; + $ok = preg_match($pattern, $value, $matches); + if (!$ok) { + throw new Exception( + pht( + 'Expected ISO8601 datetime in the format "19990105T112233Z", '. + 'found "%s".', + $value)); + } + + if (isset($matches['z'])) { + if ($timezone != 'UTC') { + throw new Exception( + pht( + 'ISO8601 date ends in "Z" indicating UTC, but a timezone other '. + 'than UTC ("%s") was specified.', + $timezone)); + } + } + + $datetime = id(new self()) + ->setYear((int)$matches['y']) + ->setMonth((int)$matches['m']) + ->setDay((int)$matches['d']) + ->setTimezone($timezone); + + if (isset($matches['h'])) { + $datetime + ->setHour((int)$matches['h']) + ->setMinute((int)$matches['i']) + ->setSecond((int)$matches['s']); + } else { + $datetime + ->setIsAllDay(true); + } + + return $datetime; + } + + public static function newFromEpoch($epoch, $timezone = 'UTC') { + $date = new DateTime('@'.$epoch); + + $zone = new DateTimeZone($timezone); + $date->setTimezone($zone); + + return id(new self()) + ->setYear((int)$date->format('Y')) + ->setMonth((int)$date->format('m')) + ->setDay((int)$date->format('d')) + ->setHour((int)$date->format('H')) + ->setMinute((int)$date->format('i')) + ->setSecond((int)$date->format('s')) + ->setTimezone($timezone); + } + + public function setYear($year) { + $this->year = $year; + return $this; + } + + public function getYear() { + return $this->year; + } + + public function setMonth($month) { + $this->month = $month; + return $this; + } + + public function getMonth() { + return $this->month; + } + + public function setDay($day) { + $this->day = $day; + return $this; + } + + public function getDay() { + return $this->day; + } + + public function setHour($hour) { + $this->hour = $hour; + return $this; + } + + public function getHour() { + return $this->hour; + } + + public function setMinute($minute) { + $this->minute = $minute; + return $this; + } + + public function getMinute() { + return $this->minute; + } + + public function setSecond($second) { + $this->second = $second; + return $this; + } + + public function getSecond() { + return $this->second; + } + + public function setTimezone($timezone) { + $this->timezone = $timezone; + return $this; + } + + public function getTimezone() { + return $this->timezone; + } + + private function getEffectiveTimezone() { + $zone = $this->getTimezone(); + if ($zone !== null) { + return $zone; + } + + $zone = $this->getViewerTimezone(); + if ($zone !== null) { + return $zone; + } + + throw new Exception( + pht( + 'Datetime has no timezone or viewer timezone.')); + } + + protected function newPHPDateTimeZone() { + $zone = $this->getEffectiveTimezone(); + return new DateTimeZone($zone); + } + + protected function newPHPDateTime() { + $zone = $this->newPHPDateTimeZone(); + + $y = $this->getYear(); + $m = $this->getMonth(); + $d = $this->getDay(); + + $h = $this->getHour(); + $i = $this->getMinute(); + $s = $this->getSecond(); + + $format = sprintf('%04d-%02d-%02d %02d:%02d:%02d', $y, $m, $d, $h, $i, $s); + + return new DateTime($format, $zone); + } + +} diff --git a/src/parser/calendar/data/PhutilCalendarContainerNode.php b/src/parser/calendar/data/PhutilCalendarContainerNode.php new file mode 100644 index 0000000..5beebb7 --- /dev/null +++ b/src/parser/calendar/data/PhutilCalendarContainerNode.php @@ -0,0 +1,30 @@ +children; + } + + final public function getChildrenOfType($type) { + $result = array(); + + foreach ($this->getChildren() as $key => $child) { + if ($child->getNodeType() != $type) { + continue; + } + $result[$key] = $child; + } + + return $result; + } + + final public function appendChild(PhutilCalendarNode $node) { + $this->children[] = $node; + return $this; + } + +} diff --git a/src/parser/calendar/data/PhutilCalendarDateTime.php b/src/parser/calendar/data/PhutilCalendarDateTime.php new file mode 100644 index 0000000..eed49f4 --- /dev/null +++ b/src/parser/calendar/data/PhutilCalendarDateTime.php @@ -0,0 +1,46 @@ +viewerTimezone = $viewer_timezone; + return $this; + } + + public function getViewerTimezone() { + return $this->viewerTimezone; + } + + public function setIsAllDay($is_all_day) { + $this->isAllDay = $is_all_day; + return $this; + } + + public function getIsAllDay() { + return $this->isAllDay; + } + + public function getEpoch() { + $datetime = $this->newPHPDateTime(); + return (int)$datetime->format('U'); + } + + public function getISO8601() { + $datetime = $this->newPHPDateTime(); + $datetime->setTimezone(new DateTimeZone('UTC')); + + if ($this->getIsAllDay()) { + return $datetime->format('Ymd'); + } else { + return $datetime->format('Ymd\\THis\\Z'); + } + } + + abstract protected function newPHPDateTimeZone(); + abstract protected function newPHPDateTime(); + +} diff --git a/src/parser/calendar/data/PhutilCalendarDocumentNode.php b/src/parser/calendar/data/PhutilCalendarDocumentNode.php new file mode 100644 index 0000000..b2e92dd --- /dev/null +++ b/src/parser/calendar/data/PhutilCalendarDocumentNode.php @@ -0,0 +1,12 @@ +getChildrenOfType(PhutilCalendarEventNode::NODETYPE); + } + +} diff --git a/src/parser/calendar/data/PhutilCalendarDuration.php b/src/parser/calendar/data/PhutilCalendarDuration.php new file mode 100644 index 0000000..0365d42 --- /dev/null +++ b/src/parser/calendar/data/PhutilCalendarDuration.php @@ -0,0 +1,66 @@ +isNegative = $is_negative; + return $this; + } + + public function getIsNegative() { + return $this->isNegative; + } + + public function setDays($days) { + $this->days = $days; + return $this; + } + + public function getDays() { + return $this->days; + } + + public function setWeeks($weeks) { + $this->weeks = $weeks; + return $this; + } + + public function getWeeks() { + return $this->weeks; + } + + public function setHours($hours) { + $this->hours = $hours; + return $this; + } + + public function getHours() { + return $this->hours; + } + + public function setMinutes($minutes) { + $this->minutes = $minutes; + return $this; + } + + public function getMinutes() { + return $this->minutes; + } + + public function setSeconds($seconds) { + $this->seconds = $seconds; + return $this; + } + + public function getSeconds() { + return $this->seconds; + } + +} diff --git a/src/parser/calendar/data/PhutilCalendarEventNode.php b/src/parser/calendar/data/PhutilCalendarEventNode.php new file mode 100644 index 0000000..37320d2 --- /dev/null +++ b/src/parser/calendar/data/PhutilCalendarEventNode.php @@ -0,0 +1,128 @@ +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; + } + +} diff --git a/src/parser/calendar/data/PhutilCalendarNode.php b/src/parser/calendar/data/PhutilCalendarNode.php new file mode 100644 index 0000000..e4d7905 --- /dev/null +++ b/src/parser/calendar/data/PhutilCalendarNode.php @@ -0,0 +1,20 @@ +getPhobjectClassConstant('NODETYPE'); + } + + final public function setAttribute($key, $value) { + $this->attributes[$key] = $value; + return $this; + } + + final public function getAttribute($key, $default = null) { + return idx($this->attributes, $key, $default); + } + +} diff --git a/src/parser/calendar/data/PhutilCalendarProxyDateTime.php b/src/parser/calendar/data/PhutilCalendarProxyDateTime.php new file mode 100644 index 0000000..6ecb75b --- /dev/null +++ b/src/parser/calendar/data/PhutilCalendarProxyDateTime.php @@ -0,0 +1,43 @@ +proxy = $proxy; + return $this; + } + + final protected function getProxy() { + return $this->proxy; + } + + public function setViewerTimezone($timezone) { + $this->getProxy()->setViewerTimezone($timezone); + return $this; + } + + public function getViewerTimezone() { + return $this->getProxy()->getViewerTimezone(); + } + + public function setIsAllDay($is_all_day) { + $this->getProxy()->setIsAllDay($is_all_day); + return $this; + } + + public function getIsAllDay() { + return $this->getProxy()->getIsAllDay(); + } + + protected function newPHPDateTimezone() { + return $this->getProxy()->newPHPDateTimezone(); + } + + protected function newPHPDateTime() { + return $this->getProxy()->newPHPDateTime(); + } + +} diff --git a/src/parser/calendar/data/PhutilCalendarRawNode.php b/src/parser/calendar/data/PhutilCalendarRawNode.php new file mode 100644 index 0000000..228b320 --- /dev/null +++ b/src/parser/calendar/data/PhutilCalendarRawNode.php @@ -0,0 +1,8 @@ +setProxy($origin); + } + + public function getOrigin() { + return $this->getProxy(); + } + + public function setDuration(PhutilCalendarDuration $duration) { + $this->duration = $duration; + return $this; + } + + public function getDuration() { + return $this->duration; + } + + protected function newPHPDateTime() { + $datetime = parent::newPHPDateTime(); + $duration = $this->getDuration(); + + if ($duration->getIsNegative()) { + $sign = '-'; + } else { + $sign = '+'; + } + + $map = array( + 'weeks' => $duration->getWeeks(), + 'days' => $duration->getDays(), + 'hours' => $duration->getHours(), + 'minutes' => $duration->getMinutes(), + 'seconds' => $duration->getSeconds(), + ); + + foreach ($map as $unit => $value) { + if (!$value) { + continue; + } + $datetime->modify("{$sign}{$value} {$unit}"); + } + + return $datetime; + } + +} diff --git a/src/parser/calendar/data/PhutilCalendarRootNode.php b/src/parser/calendar/data/PhutilCalendarRootNode.php new file mode 100644 index 0000000..de22587 --- /dev/null +++ b/src/parser/calendar/data/PhutilCalendarRootNode.php @@ -0,0 +1,12 @@ +getChildrenOfType(PhutilCalendarDocumentNode::NODETYPE); + } + +} diff --git a/src/parser/calendar/data/PhutilCalendarUserNode.php b/src/parser/calendar/data/PhutilCalendarUserNode.php new file mode 100644 index 0000000..ea81e0d --- /dev/null +++ b/src/parser/calendar/data/PhutilCalendarUserNode.php @@ -0,0 +1,40 @@ +name = $name; + return $this; + } + + public function getName() { + return $this->name; + } + + public function setURI($uri) { + $this->uri = $uri; + return $this; + } + + public function getURI() { + return $this->uri; + } + + public function setStatus($status) { + $this->status = $status; + return $this; + } + + public function getStatus() { + return $this->status; + } + +} diff --git a/src/parser/calendar/ics/PhutilICSParser.php b/src/parser/calendar/ics/PhutilICSParser.php new file mode 100644 index 0000000..03c7428 --- /dev/null +++ b/src/parser/calendar/ics/PhutilICSParser.php @@ -0,0 +1,822 @@ +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; + } + } + } + + 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; + } + + } + + private function newTextFromProperty(array $parameters, array $value) { + $value = $value['value']; + return implode("\n\n", $value); + } + + 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); + + $pattern = + '/^'. + '(?P[+-])?'. + 'P'. + '(?:'. + '(?P\d+)W'. + '|'. + '(?:(?:(?P\d+)D)?'. + '(?:T(?:(?P\d+)H)?(?:(?P\d+)M)?(?:(?P\d+)S)?)?'. + ')'. + ')'. + '\z/'; + + $matches = null; + $ok = preg_match($pattern, $value, $matches); + if (!$ok) { + $this->raiseParseFailure( + self::PARSE_BAD_DURATION, + pht( + 'Expected DURATION in the format "P12DT3H4M5S", found '. + '"%s".', + $value)); + } + + $is_negative = (idx($matches, 'sign') == '-'); + + $duration = id(new PhutilCalendarDuration()) + ->setIsNegative($is_negative) + ->setWeeks((int)idx($matches, 'W', 0)) + ->setDays((int)idx($matches, 'D', 0)) + ->setHours((int)idx($matches, 'H', 0)) + ->setMinutes((int)idx($matches, 'M', 0)) + ->setSeconds((int)idx($matches, 'S', 0)); + + return $duration; + } + + 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/PhutilICSParserException.php b/src/parser/calendar/ics/PhutilICSParserException.php new file mode 100644 index 0000000..09563ff --- /dev/null +++ b/src/parser/calendar/ics/PhutilICSParserException.php @@ -0,0 +1,16 @@ +parserFailureCode = $code; + return $this; + } + + public function getParserFailureCode() { + return $this->parserFailureCode; + } + +} diff --git a/src/parser/calendar/ics/PhutilICSWriter.php b/src/parser/calendar/ics/PhutilICSWriter.php new file mode 100644 index 0000000..59362ce --- /dev/null +++ b/src/parser/calendar/ics/PhutilICSWriter.php @@ -0,0 +1,336 @@ +getChildren() as $child) { + $out[] = $this->writeNode($child); + } + + return implode('', $out); + } + + private function writeNode(PhutilCalendarNode $node) { + if (!$this->getICSNodeType($node)) { + return null; + } + + $out = array(); + + $out[] = $this->writeBeginNode($node); + $out[] = $this->writeNodeProperties($node); + + if ($node instanceof PhutilCalendarContainerNode) { + foreach ($node->getChildren() as $child) { + $out[] = $this->writeNode($child); + } + } + + $out[] = $this->writeEndNode($node); + + return implode('', $out); + } + + private function writeBeginNode(PhutilCalendarNode $node) { + $type = $this->getICSNodeType($node); + return $this->wrapICSLine("BEGIN:{$type}"); + } + + private function writeEndNode(PhutilCalendarNode $node) { + $type = $this->getICSNodeType($node); + return $this->wrapICSLine("END:{$type}"); + } + + private function writeNodeProperties(PhutilCalendarNode $node) { + $properties = $this->getNodeProperties($node); + + $out = array(); + foreach ($properties as $property) { + $propname = $property['name']; + $propvalue = $property['value']; + + $propline = array(); + $propline[] = $propname; + + foreach ($property['parameters'] as $parameter) { + $paramname = $parameter['name']; + $paramvalue = $parameter['value']; + $propline[] = ";{$paramname}={$paramvalue}"; + } + + $propline[] = ":{$propvalue}"; + $propline = implode('', $propline); + + $out[] = $this->wrapICSLine($propline); + } + + return implode('', $out); + } + + private function getICSNodeType(PhutilCalendarNode $node) { + switch ($node->getNodeType()) { + case PhutilCalendarDocumentNode::NODETYPE: + return 'VCALENDAR'; + case PhutilCalendarEventNode::NODETYPE: + return 'VEVENT'; + default: + return null; + } + } + + private function wrapICSLine($line) { + $out = array(); + $buf = ''; + + // NOTE: The line may contain sequences of combining characters which are + // more than 80 bytes in length. If it does, we'll split them in the + // middle of the sequence. This is okay and generally anticipated by + // RFC5545, which even allows implementations to split multibyte + // characters. The sequence will be stitched back together properly by + // whatever is parsing things. + + foreach (phutil_utf8v($line) as $character) { + // If adding this character would bring the line over 75 bytes, start + // a new line. + if (strlen($buf) + strlen($character) > 75) { + $out[] = $buf."\r\n"; + $buf = ' '; + } + + $buf .= $character; + } + + $out[] = $buf."\r\n"; + + return implode('', $out); + } + + private function getNodeProperties(PhutilCalendarNode $node) { + switch ($node->getNodeType()) { + case PhutilCalendarDocumentNode::NODETYPE: + return $this->getDocumentNodeProperties($node); + case PhutilCalendarEventNode::NODETYPE: + return $this->getEventNodeProperties($node); + default: + return array(); + } + } + + private function getDocumentNodeProperties( + PhutilCalendarDocumentNode $event) { + $properties = array(); + + $properties[] = $this->newTextProperty( + 'VERSION', + '2.0'); + + $properties[] = $this->newTextProperty( + 'PRODID', + '-//Phacility//Phabricator//EN'); + + return $properties; + } + + private function getEventNodeProperties(PhutilCalendarEventNode $event) { + $properties = array(); + + $uid = $event->getUID(); + if (!strlen($uid)) { + throw new Exception( + pht( + 'Unable to write ICS document: event has no UID, but each event '. + 'MUST have a UID.')); + } + $properties[] = $this->newTextProperty( + 'UID', + $uid); + + $created = $event->getCreatedDateTime(); + if ($created) { + $properties[] = $this->newDateTimeProperty( + 'CREATED', + $event->getCreatedDateTime()); + } + + $dtstamp = $event->getModifiedDateTime(); + if (!$dtstamp) { + throw new Exception( + pht( + 'Unable to write ICS document: event has no modified time, but '. + 'each event MUST have a modified time.')); + } + $properties[] = $this->newDateTimeProperty( + 'DTSTAMP', + $dtstamp); + + $dtstart = $event->getStartDateTime(); + if ($dtstart) { + $properties[] = $this->newDateTimeProperty( + 'DTSTART', + $dtstart); + } + + $dtend = $event->getEndDateTime(); + if ($dtend) { + $properties[] = $this->newDateTimeProperty( + 'DTEND', + $event->getEndDateTime()); + } + + $name = $event->getName(); + if (strlen($name)) { + $properties[] = $this->newTextProperty( + 'SUMMARY', + $name); + } + + $description = $event->getDescription(); + if (strlen($description)) { + $properties[] = $this->newTextProperty( + 'DESCRIPTION', + $description); + } + + $organizer = $event->getOrganizer(); + if ($organizer) { + $properties[] = $this->newUserProperty( + 'ORGANIZER', + $organizer); + } + + $attendees = $event->getAttendees(); + if ($attendees) { + foreach ($attendees as $attendee) { + $properties[] = $this->newUserProperty( + 'ATTENDEE', + $attendee); + } + } + + return $properties; + } + + private function newTextProperty( + $name, + $value, + array $parameters = array()) { + + $map = array( + '\\' => '\\\\', + ',' => '\\,', + "\n" => '\\n', + ); + + $value = (array)$value; + foreach ($value as $k => $v) { + $v = str_replace(array_keys($map), array_values($map), $v); + $value[$k] = $v; + } + + $value = implode(',', $value); + + return $this->newProperty($name, $value, $parameters); + } + + private function newDateTimeProperty( + $name, + PhutilCalendarDateTime $value, + array $parameters = array()) { + $datetime = $value->getISO8601(); + + if ($value->getIsAllDay()) { + $parameters[] = array( + 'name' => 'VALUE', + 'values' => array( + 'DATE', + ), + ); + } + + return $this->newProperty($name, $datetime, $parameters); + } + + private function newUserProperty( + $name, + PhutilCalendarUserNode $value, + array $parameters = array()) { + + $parameters[] = array( + 'name' => 'CN', + 'values' => array( + $value->getName(), + ), + ); + + $partstat = null; + switch ($value->getStatus()) { + case PhutilCalendarUserNode::STATUS_INVITED: + $partstat = 'NEEDS-ACTION'; + break; + case PhutilCalendarUserNode::STATUS_ACCEPTED: + $partstat = 'ACCEPTED'; + break; + case PhutilCalendarUserNode::STATUS_DECLINED: + $partstat = 'DECLINED'; + break; + } + + if ($partstat !== null) { + $parameters[] = array( + 'name' => 'PARTSTAT', + 'values' => array( + $partstat, + ), + ); + } + + // TODO: We could reasonably fill in "ROLE" and "RSVP" here too, but it + // isn't clear if these are important to external programs or not. + + return $this->newProperty($name, $value->getURI(), $parameters); + } + + private function newProperty( + $name, + $value, + array $parameters = array()) { + + $map = array( + '^' => '^^', + "\n" => '^n', + '"' => "^'", + ); + + $writable_params = array(); + foreach ($parameters as $k => $parameter) { + $value_list = array(); + foreach ($parameter['values'] as $v) { + $v = str_replace(array_keys($map), array_values($map), $v); + + // If the parameter value isn't a very simple one, quote it. + + // RFC5545 says that we MUST quote it if it has a colon, a semicolon, + // or a comma, and that we MUST quote it if it's a URI. + if (!preg_match('/^[A-Za-z0-9-]*\z/', $v)) { + $v = '"'.$v.'"'; + } + + $value_list[] = $v; + } + + $writable_params[] = array( + 'name' => $parameter['name'], + 'value' => implode(',', $value_list), + ); + } + + return array( + 'name' => $name, + 'value' => $value, + 'parameters' => $writable_params, + ); + } + +} diff --git a/src/parser/calendar/ics/__tests__/PhutilICSParserTestCase.php b/src/parser/calendar/ics/__tests__/PhutilICSParserTestCase.php new file mode 100644 index 0000000..a4732c3 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/PhutilICSParserTestCase.php @@ -0,0 +1,294 @@ +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 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__/PhutilICSWriterTestCase.php b/src/parser/calendar/ics/__tests__/PhutilICSWriterTestCase.php new file mode 100644 index 0000000..c9fb670 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/PhutilICSWriterTestCase.php @@ -0,0 +1,111 @@ +setUID('tea-time') + ->setName('Tea Time') + ->setDescription( + "Tea and, perhaps, crumpets.\n". + "Your presence is requested!\n". + "This is a long list of types of tea to test line wrapping: {$teas}.") + ->setCreatedDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160915T070000Z')) + ->setModifiedDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160915T070000Z')) + ->setStartDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160916T150000Z')) + ->setEndDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160916T160000Z')); + + $ics_data = $this->writeICSSingleEvent($event); + + $this->assertICS('writer-tea-time.ics', $ics_data); + } + + public function testICSWriterAllDay() { + $event = id(new PhutilCalendarEventNode()) + ->setUID('christmas-day') + ->setName('Christmas 2016') + ->setDescription('A minor religious holiday.') + ->setCreatedDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160901T232425Z')) + ->setModifiedDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20160901T232425Z')) + ->setStartDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20161225')) + ->setEndDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20161226')); + + $ics_data = $this->writeICSSingleEvent($event); + + $this->assertICS('writer-christmas.ics', $ics_data); + } + + public function testICSWriterUsers() { + $event = id(new PhutilCalendarEventNode()) + ->setUID('office-party') + ->setName('Office Party') + ->setCreatedDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20161001T120000Z')) + ->setModifiedDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20161001T120000Z')) + ->setStartDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20161215T200000Z')) + ->setEndDateTime( + PhutilCalendarAbsoluteDateTime::newFromISO8601('20161215T230000Z')) + ->setOrganizer( + id(new PhutilCalendarUserNode()) + ->setName('Big Boss') + ->setURI('mailto:big.boss@example.com')) + ->addAttendee( + id(new PhutilCalendarUserNode()) + ->setName('Milton') + ->setStatus(PhutilCalendarUserNode::STATUS_INVITED) + ->setURI('mailto:milton@example.com')) + ->addAttendee( + id(new PhutilCalendarUserNode()) + ->setName('Nancy') + ->setStatus(PhutilCalendarUserNode::STATUS_ACCEPTED) + ->setURI('mailto:nancy@example.com')); + + $ics_data = $this->writeICSSingleEvent($event); + $this->assertICS('writer-office-party.ics', $ics_data); + } + + private function writeICSSingleEvent(PhutilCalendarEventNode $event) { + $calendar = id(new PhutilCalendarDocumentNode()) + ->appendChild($event); + + $root = id(new PhutilCalendarRootNode()) + ->appendChild($calendar); + + return $this->writeICS($root); + } + + private function writeICS(PhutilCalendarRootNode $root) { + return id(new PhutilICSWriter()) + ->writeICSDocument($root); + } + + private function assertICS($name, $actual) { + $path = dirname(__FILE__).'/data/'.$name; + $data = Filesystem::readFile($path); + $this->assertEqual($data, $actual, pht('ICS: %s', $name)); + } + +} diff --git a/src/parser/calendar/ics/__tests__/data/duration.ics b/src/parser/calendar/ics/__tests__/data/duration.ics new file mode 100644 index 0000000..8f2a8b5 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/duration.ics @@ -0,0 +1,8 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART:20160719T095722Z +DURATION:P1DT17H4M23S +SUMMARY:Duration Event +DESCRIPTION:This is an event with a complex duration. +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-bad-base64.ics b/src/parser/calendar/ics/__tests__/data/err-bad-base64.ics new file mode 100644 index 0000000..ff1997e --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-bad-base64.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +DATA;VALUE=BINARY;ENCODING=BASE64: +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-bad-boolean.ics b/src/parser/calendar/ics/__tests__/data/err-bad-boolean.ics new file mode 100644 index 0000000..f5cd06c --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-bad-boolean.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +DUCK;VALUE=BOOLEAN:QUACK +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-bad-datetime.ics b/src/parser/calendar/ics/__tests__/data/err-bad-datetime.ics new file mode 100644 index 0000000..b6dada8 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-bad-datetime.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART:quack +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-bad-duration.ics b/src/parser/calendar/ics/__tests__/data/err-bad-duration.ics new file mode 100644 index 0000000..3d0eb7b --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-bad-duration.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +DURATION:quack +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-bad-tzid.ics b/src/parser/calendar/ics/__tests__/data/err-bad-tzid.ics new file mode 100644 index 0000000..cf401ad --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-bad-tzid.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;TZID=quack:20130101 +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-empty-datetime.ics b/src/parser/calendar/ics/__tests__/data/err-empty-datetime.ics new file mode 100644 index 0000000..554fb23 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-empty-datetime.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART: +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-empty-duration.ics b/src/parser/calendar/ics/__tests__/data/err-empty-duration.ics new file mode 100644 index 0000000..33b4d78 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-empty-duration.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +DURATION: +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-extra-end.ics b/src/parser/calendar/ics/__tests__/data/err-extra-end.ics new file mode 100644 index 0000000..b0bb4bc --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-extra-end.ics @@ -0,0 +1 @@ +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-initial-unfold.ics b/src/parser/calendar/ics/__tests__/data/err-initial-unfold.ics new file mode 100644 index 0000000..b577782 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-initial-unfold.ics @@ -0,0 +1,2 @@ + BEGIN:VCALENDAR +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-malformed-double-quote.ics b/src/parser/calendar/ics/__tests__/data/err-malformed-double-quote.ics new file mode 100644 index 0000000..cd7623e --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-malformed-double-quote.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +A;B="C:D +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-malformed-parameter.ics b/src/parser/calendar/ics/__tests__/data/err-malformed-parameter.ics new file mode 100644 index 0000000..8968cfb --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-malformed-parameter.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +A;B:C +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-malformed-property.ics b/src/parser/calendar/ics/__tests__/data/err-malformed-property.ics new file mode 100644 index 0000000..2121531 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-malformed-property.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +PEANUTBUTTER&JELLY:sandwich +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-many-datetime.ics b/src/parser/calendar/ics/__tests__/data/err-many-datetime.ics new file mode 100644 index 0000000..5e617a4 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-many-datetime.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART:20130101,20130101 +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-many-duration.ics b/src/parser/calendar/ics/__tests__/data/err-many-duration.ics new file mode 100644 index 0000000..d43d9ef --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-many-duration.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +DURATION:P1W,P2W +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-missing-end.ics b/src/parser/calendar/ics/__tests__/data/err-missing-end.ics new file mode 100644 index 0000000..d33f05b --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-missing-end.ics @@ -0,0 +1,2 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT diff --git a/src/parser/calendar/ics/__tests__/data/err-missing-value.ics b/src/parser/calendar/ics/__tests__/data/err-missing-value.ics new file mode 100644 index 0000000..39d83aa --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-missing-value.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +TRIANGLE;color=red +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-mixmatched-sections.ics b/src/parser/calendar/ics/__tests__/data/err-mixmatched-sections.ics new file mode 100644 index 0000000..e3a1ab0 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-mixmatched-sections.ics @@ -0,0 +1,4 @@ +BEGIN:A +BEGIN:B +END:A +END:B diff --git a/src/parser/calendar/ics/__tests__/data/err-multiple-parameters.ics b/src/parser/calendar/ics/__tests__/data/err-multiple-parameters.ics new file mode 100644 index 0000000..38da44f --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-multiple-parameters.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;TZID=A,B:20160915T090000 +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-root-property.ics b/src/parser/calendar/ics/__tests__/data/err-root-property.ics new file mode 100644 index 0000000..a36bd4c --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-root-property.ics @@ -0,0 +1 @@ +NAME:value diff --git a/src/parser/calendar/ics/__tests__/data/err-unescaped-backslash.ics b/src/parser/calendar/ics/__tests__/data/err-unescaped-backslash.ics new file mode 100644 index 0000000..a1ba94b --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-unescaped-backslash.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +STORY:The duck coughed up an unescaped backslash: \ +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-unexpected-child.ics b/src/parser/calendar/ics/__tests__/data/err-unexpected-child.ics new file mode 100644 index 0000000..0478c40 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-unexpected-child.ics @@ -0,0 +1,6 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +BEGIN:TEST +END:TEST +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/err-unexpected-text.ics b/src/parser/calendar/ics/__tests__/data/err-unexpected-text.ics new file mode 100644 index 0000000..30873a2 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/err-unexpected-text.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +SQUARE;color=red" +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/floating.ics b/src/parser/calendar/ics/__tests__/data/floating.ics new file mode 100644 index 0000000..eecfe23 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/floating.ics @@ -0,0 +1,8 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART:20150101T000000 +DURATION:P1D +SUMMARY:New Year's 2015 +DESCRIPTION:This is an event with a floating start time. +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/good-boolean.ics b/src/parser/calendar/ics/__tests__/data/good-boolean.ics new file mode 100644 index 0000000..d281b17 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/good-boolean.ics @@ -0,0 +1,5 @@ +BEGIN:VCALENDAR +BEGIN:VEVENT +DUCK;VALUE=BOOLEAN:TRUE +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/multiple-vcalendars.ics b/src/parser/calendar/ics/__tests__/data/multiple-vcalendars.ics new file mode 100644 index 0000000..68e99a6 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/multiple-vcalendars.ics @@ -0,0 +1,4 @@ +BEGIN:VCALENDAR +END:VCALENDAR +BEGIN:VCALENDAR +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/simple.ics b/src/parser/calendar/ics/__tests__/data/simple.ics new file mode 100644 index 0000000..8181a24 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/simple.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +BEGIN:VEVENT +CREATED:20160908T172702Z +UID:1CEB57AF-0C9C-402D-B3BD-D75BD4843F68 +DTSTART;TZID=America/Los_Angeles:20160915T090000 +DTEND;TZID=America/Los_Angeles:20160915T100000 +SUMMARY:Simple Event +DESCRIPTION:This is a simple event. +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/writer-christmas.ics b/src/parser/calendar/ics/__tests__/data/writer-christmas.ics new file mode 100644 index 0000000..4624d15 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/writer-christmas.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Phacility//Phabricator//EN +BEGIN:VEVENT +UID:christmas-day +CREATED:20160901T232425Z +DTSTAMP:20160901T232425Z +DTSTART;VALUE=DATE:20161225 +DTEND;VALUE=DATE:20161226 +SUMMARY:Christmas 2016 +DESCRIPTION:A minor religious holiday. +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/writer-office-party.ics b/src/parser/calendar/ics/__tests__/data/writer-office-party.ics new file mode 100644 index 0000000..a2fbc81 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/writer-office-party.ics @@ -0,0 +1,15 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Phacility//Phabricator//EN +BEGIN:VEVENT +UID:office-party +CREATED:20161001T120000Z +DTSTAMP:20161001T120000Z +DTSTART:20161215T200000Z +DTEND:20161215T230000Z +SUMMARY:Office Party +ORGANIZER;CN="Big Boss":mailto:big.boss@example.com +ATTENDEE;CN=Milton;PARTSTAT=NEEDS-ACTION:mailto:milton@example.com +ATTENDEE;CN=Nancy;PARTSTAT=ACCEPTED:mailto:nancy@example.com +END:VEVENT +END:VCALENDAR diff --git a/src/parser/calendar/ics/__tests__/data/writer-tea-time.ics b/src/parser/calendar/ics/__tests__/data/writer-tea-time.ics new file mode 100644 index 0000000..e275fa9 --- /dev/null +++ b/src/parser/calendar/ics/__tests__/data/writer-tea-time.ics @@ -0,0 +1,16 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Phacility//Phabricator//EN +BEGIN:VEVENT +UID:tea-time +CREATED:20160915T070000Z +DTSTAMP:20160915T070000Z +DTSTART:20160916T150000Z +DTEND:20160916T160000Z +SUMMARY:Tea Time +DESCRIPTION:Tea and\, perhaps\, crumpets.\nYour presence is requested!\nThi + s is a long list of types of tea to test line wrapping: earl grey tea\, En + glish breakfast tea\, black tea\, green tea\, t-rex\, oolong tea\, mint te + a\, tea with milk. +END:VEVENT +END:VCALENDAR