diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a84392..808adfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,4 +8,15 @@ where applicable. ## Unreleased -- Initial SymPress Kernel package documentation. +### Added + +- Bridge `App::enableDebug()` and `App::disableDebug()` to an optional profiler service. + +### Changed + +- Split package-manager actions, package-manager rendering, kernel configuration, runtime container cache, and core service registration into focused collaborators. +- Adopt shared SymPress QA tooling and PHP 8.5 package constraints. + +### Fixed + +- Normalize mixed metadata, route, hook, environment, and WordPress context inputs before using them as typed kernel state. diff --git a/composer.json b/composer.json index dfc50fa..273c615 100644 --- a/composer.json +++ b/composer.json @@ -31,8 +31,7 @@ "symfony/yaml": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^11.5", - "sympress/coding-standards": "dev-main" + "sympress/qa": "dev-main" }, "autoload": { "psr-4": { @@ -55,24 +54,33 @@ "scripts": { "cs": [ "Composer\\Config::disableProcessTimeout", - "phpcs --standard=phpcs.xml.dist" + "qa cs" ], "cs:fix": [ "Composer\\Config::disableProcessTimeout", - "phpcbf --standard=phpcs.xml.dist" + "qa cs:fix" + ], + "static-analysis": [ + "Composer\\Config::disableProcessTimeout", + "qa static-analysis" ], "tests": [ "Composer\\Config::disableProcessTimeout", - "phpunit --configuration phpunit.xml.dist --no-coverage" + "qa tests" + ], + "test": [ + "@tests" ], "qa": [ "@cs", + "@static-analysis", "@tests" ] }, "config": { "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": true + "dealerdirect/phpcodesniffer-composer-installer": true, + "phpstan/extension-installer": true }, "sort-packages": true }, diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 6bf0b62..f36118b 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -15,6 +15,7 @@ vendor/* .phpunit.cache/* + tests/Support/TestEnvironment.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..234bf1a --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +parameters: + level: max + paths: + - src + bootstrapFiles: + - tests/Support/TestEnvironment.php + treatPhpDocTypesAsCertain: false diff --git a/src/Admin/PackageManagerActions.php b/src/Admin/PackageManagerActions.php new file mode 100644 index 0000000..fc43d1a --- /dev/null +++ b/src/Admin/PackageManagerActions.php @@ -0,0 +1,395 @@ + $this->activate($package), + 'deactivate' => $this->deactivate($package), + 'delete' => $this->delete($package), + default => new \WP_Error( + 'kernel_unknown_package_action', + __('Unknown package action.', 'sympress-kernel'), + ), + }; + } + + public function canRun(string $action, PackageMetadata $package): bool + { + if (!$this->fileModificationsAllowed($package)) { + return false; + } + + if ($action === 'delete') { + if ($package->isTheme()) { + return current_user_can('delete_themes'); + } + + return $package->isPlugin() && current_user_can('delete_plugins'); + } + + if ($action === 'deactivate') { + if ($package->isTheme()) { + return current_user_can('switch_themes'); + } + + return $package->isPlugin() && current_user_can('activate_plugins'); + } + + if ($action === 'activate' && $package->isTheme()) { + return current_user_can('switch_themes'); + } + + return $action === 'activate' + && $package->isPlugin() + && current_user_can('activate_plugins'); + } + + public function isKnownAction(string $action): bool + { + return in_array($action, ['activate', 'deactivate', 'delete'], true); + } + + public function isActionAvailable(string $action, PackageMetadata $package): bool + { + if ($package->isMustUsePlugin()) { + return false; + } + + return match ($action) { + 'activate' => !$package->active() && ($package->isPlugin() || $package->isTheme()), + 'deactivate' => $package->active() && ($package->isPlugin() || $package->isTheme()), + 'delete' => !$package->active() && ($package->isPlugin() || $package->isTheme()), + default => false, + }; + } + + public function actionUnavailableMessage(string $action, PackageMetadata $package): string + { + if ($package->isMustUsePlugin()) { + return __('Must-use packages cannot be changed here.', 'sympress-kernel'); + } + + if ($action === 'delete' && $package->active()) { + return __('Deactivate the package before deleting it.', 'sympress-kernel'); + } + + return __('This package action is not available.', 'sympress-kernel'); + } + + public function deleteSymlinkPackage(PackageMetadata $package): ?\WP_Error + { + if (!$this->isManagedSymlinkPackage($package)) { + return new \WP_Error( + 'kernel_package_unmanaged_symlink', + __('Only managed WordPress package symlinks can be deleted here.', 'sympress-kernel'), + ); + } + + $this->beforeSymlinkDelete($package); + $deleted = @unlink($package->path()); + $this->afterSymlinkDelete($package, $deleted); + + if (!$deleted) { + return new \WP_Error( + 'kernel_package_delete_failed', + __('The package symlink could not be deleted.', 'sympress-kernel'), + ); + } + + if ($package->isPlugin() && function_exists('wp_clean_plugins_cache')) { + wp_clean_plugins_cache(true); + } + + if ($package->isTheme() && function_exists('wp_clean_themes_cache')) { + wp_clean_themes_cache(true); + } + + return null; + } + + private function activate(PackageMetadata $package): ?\WP_Error + { + if ($package->isPlugin()) { + $this->loadPluginAdminFunctions(); + $result = activate_plugin($package->entry(), '', is_network_admin(), false); + + return $result instanceof \WP_Error ? $result : null; + } + + if ($package->isTheme()) { + switch_theme($package->entry()); + + return null; + } + + return new \WP_Error( + 'kernel_package_not_activatable', + __('This package type cannot be activated here.', 'sympress-kernel'), + ); + } + + private function deactivate(PackageMetadata $package): ?\WP_Error + { + if ($package->isPlugin()) { + $this->loadPluginAdminFunctions(); + deactivate_plugins($package->entry(), false, is_network_admin()); + + return null; + } + + if ($package->isTheme()) { + return $this->deactivateTheme($package); + } + + return new \WP_Error( + 'kernel_package_not_deactivatable', + __('This package type cannot be deactivated here.', 'sympress-kernel'), + ); + } + + private function deactivateTheme(PackageMetadata $package): ?\WP_Error + { + $fallback = $this->fallbackTheme($package->entry()); + + if ($fallback === '') { + return new \WP_Error( + 'kernel_package_theme_without_fallback', + __( + 'Install another theme before deactivating this theme package.', + 'sympress-kernel', + ), + ); + } + + switch_theme($fallback); + + return null; + } + + private function fallbackTheme(string $currentTheme): string + { + $defaultTheme = defined('WP_DEFAULT_THEME') ? (string) WP_DEFAULT_THEME : ''; + + if ($this->themeExists($defaultTheme) && $defaultTheme !== $currentTheme) { + return $defaultTheme; + } + + if (!function_exists('wp_get_themes')) { + return ''; + } + + foreach (wp_get_themes() as $stylesheet => $theme) { + if (!is_string($stylesheet) || $stylesheet === $currentTheme) { + continue; + } + + if (!$theme->exists()) { + continue; + } + + return $stylesheet; + } + + return ''; + } + + private function themeExists(string $stylesheet): bool + { + if ($stylesheet === '' || !function_exists('wp_get_theme')) { + return false; + } + + return wp_get_theme($stylesheet)->exists(); + } + + private function delete(PackageMetadata $package): ?\WP_Error + { + if ($package->active()) { + return new \WP_Error( + 'kernel_package_active_delete', + __('Deactivate the package before deleting it.', 'sympress-kernel'), + ); + } + + if (is_link($package->path())) { + return $this->deleteSymlinkPackage($package); + } + + if ($package->isPlugin()) { + $this->loadPluginAdminFunctions(); + $result = delete_plugins([$package->entry()]); + + return $this->deleteResultToError($result); + } + + if ($package->isTheme()) { + $this->loadThemeAdminFunctions(); + $result = delete_theme($package->entry(), admin_url('admin.php')); + + return $this->deleteResultToError($result); + } + + return new \WP_Error( + 'kernel_package_not_deletable', + __('This package type cannot be deleted here.', 'sympress-kernel'), + ); + } + + private function beforeSymlinkDelete(PackageMetadata $package): void + { + if ($package->isPlugin()) { + $this->loadPluginAdminFunctions(); + + if (is_uninstallable_plugin($package->entry())) { + uninstall_plugin($package->entry()); + } + + if (function_exists('do_action')) { + do_action('delete_plugin', $package->entry()); + } + } + + if (!$package->isTheme()) { + return; + } + + if (!function_exists('do_action')) { + return; + } + + do_action('delete_theme', $package->entry()); + } + + private function afterSymlinkDelete(PackageMetadata $package, bool $deleted): void + { + if ($package->isPlugin() && function_exists('do_action')) { + do_action('deleted_plugin', $package->entry(), $deleted); + } + + if (!$package->isTheme() || !function_exists('do_action')) { + return; + } + + do_action('deleted_theme', $package->entry(), $deleted); + } + + private function deleteResultToError(mixed $result): ?\WP_Error + { + if ($result === true) { + return null; + } + + if ($result instanceof \WP_Error) { + return $result; + } + + return new \WP_Error( + 'kernel_package_delete_failed', + __('The package could not be deleted.', 'sympress-kernel'), + ); + } + + private function fileModificationsAllowed(PackageMetadata $package): bool + { + $context = $package->isTheme() ? 'themes' : 'plugins'; + + if (function_exists('wp_is_file_mod_allowed')) { + return wp_is_file_mod_allowed($context); + } + + return !defined('DISALLOW_FILE_MODS') || !constant('DISALLOW_FILE_MODS'); + } + + private function isManagedSymlinkPackage(PackageMetadata $package): bool + { + $root = $package->isTheme() ? $this->themeRootDirectory() : $this->pluginRootDirectory(); + $entryRoot = $this->entryRoot($package->entry()); + + if ($root === null || $entryRoot === '') { + return false; + } + + return $this->normalizePath($package->path()) === $this->normalizePath(sprintf('%s/%s', $root, $entryRoot)); + } + + private function entryRoot(string $entry): string + { + $parts = array_values( + array_filter( + explode('/', str_replace('\\', '/', $entry)), + static fn (string $part): bool => $part !== '', + ), + ); + + return $parts[0] ?? ''; + } + + private function pluginRootDirectory(): ?string + { + if (defined('WP_PLUGIN_DIR')) { + return (string) WP_PLUGIN_DIR; + } + + if (defined('WP_CONTENT_DIR')) { + return sprintf('%s/plugins', rtrim((string) WP_CONTENT_DIR, '/')); + } + + return null; + } + + private function themeRootDirectory(): ?string + { + if (function_exists('get_theme_root')) { + $themeRoot = get_theme_root(); + + if ($themeRoot !== '') { + return $themeRoot; + } + } + + if (defined('WP_CONTENT_DIR')) { + return sprintf('%s/themes', rtrim((string) WP_CONTENT_DIR, '/')); + } + + return null; + } + + private function normalizePath(string $path): string + { + return rtrim(str_replace('\\', '/', $path), '/'); + } + + private function loadPluginAdminFunctions(): void + { + if (!function_exists('activate_plugin') || !function_exists('delete_plugins')) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + if (function_exists('request_filesystem_credentials')) { + return; + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + private function loadThemeAdminFunctions(): void + { + if (!function_exists('delete_theme')) { + require_once ABSPATH . 'wp-admin/includes/theme.php'; + } + + if (function_exists('request_filesystem_credentials')) { + return; + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + } +} diff --git a/src/Admin/PackageManagerPage.php b/src/Admin/PackageManagerPage.php index 943b5ee..d95038a 100644 --- a/src/Admin/PackageManagerPage.php +++ b/src/Admin/PackageManagerPage.php @@ -18,10 +18,14 @@ final class PackageManagerPage private const string VIEW_QUERY_VAR = 'package_status'; private const string BULK_NONCE_ACTION = 'bulk-packages'; + private readonly PackageManagerActions $actions; + public function __construct( private readonly PackageDiscovery $packages, private readonly bool $enabled = false, + ?PackageManagerActions $actions = null, ) { + $this->actions = $actions ?? new PackageManagerActions(); } public function register(): void @@ -170,15 +174,7 @@ private function handleBulkAction(): void private function runPackageAction(string $action, PackageMetadata $package): ?\WP_Error { - return match ($action) { - 'activate' => $this->activate($package), - 'deactivate' => $this->deactivate($package), - 'delete' => $this->delete($package), - default => new \WP_Error( - 'kernel_unknown_package_action', - __('Unknown package action.', 'sympress-kernel'), - ), - }; + return $this->actions->run($action, $package); } public function render(): void @@ -188,372 +184,13 @@ public function render(): void } $packages = $this->packages->all(); - $visiblePackages = $this->filterPackages($packages, $this->currentView()); - - ?> -
-

- -

-
- - renderNotice(); ?> - renderViews($packages); ?> - -
- - currentView() !== 'all') : ?> - - - - renderTableNav($visiblePackages, 'top'); ?> - renderTable($visiblePackages); ?> - renderTableNav($visiblePackages, 'bottom'); ?> -
-
- $packages */ - private function renderTableNav(array $packages, string $which): void - { - ?> -
- renderBulkActions($which); ?> -
- - itemCountLabel(count($packages))); ?> - -
-
-
- -
- - - -
- $packages */ - private function renderTable(array $packages): void - { - ?> - - - - - - - - - - - - - - - - - - - renderRow($package); ?> - - - - - - - - - - - -
- renderSelectAllCheckbox('cb-select-all-1'); ?> - - - - - - - - -
- -
- renderSelectAllCheckbox('cb-select-all-2'); ?> - - - - - - - - -
- active() ? 'active' : 'inactive'; - - ?> - - - renderRowCheckbox($package); ?> - - - name()); ?> - renderRowActions($package); ?> - - - -
-

description($package)); ?>

-
-
- metaLine($package)); ?> -
- - - typeLabel()); ?> - - - statusLabel()); ?> - - - - - - hasBulkAction($package)) { - echo ' '; - - return; - } - - $id = sprintf('package-%s', sanitize_html_class($package->package())); - - ?> - - - rowActions($package); - - if ($actions === []) { - return; - } - - echo '
'; - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - echo implode(' | ', $actions); - echo '
'; - } - - /** @return list */ - private function rowActions(PackageMetadata $package): array - { - if ($package->isMustUsePlugin()) { - return [ - sprintf( - '%s', - esc_html__('Must-use', 'sympress-kernel'), - ), - ]; - } - - $actions = []; - - if ($this->isActionAvailable('deactivate', $package)) { - $actions[] = $this->actionLink('deactivate', $package, __('Deactivate')); - } - - if ($this->isActionAvailable('activate', $package)) { - $actions[] = $this->actionLink('activate', $package, __('Activate')); - } - - if ($this->isActionAvailable('delete', $package)) { - $actions[] = $this->actionLink('delete', $package, __('Delete'), 'delete'); - } - - return array_values(array_filter($actions)); - } - - private function actionLink( - string $action, - PackageMetadata $package, - string $label, - string $class = '', - ): string { - - if (!$this->canRun($action, $package)) { - return ''; - } - - $attributes = [ - 'href' => esc_url($this->actionUrl($action, $package)), - ]; - - if ($class !== '') { - $attributes['class'] = $class; - } - - if ($action === 'delete') { - $confirm = wp_json_encode( - __('Are you sure you want to delete this package?', 'sympress-kernel'), - ); - $attributes['onclick'] = sprintf( - 'return confirm(%s);', - is_string($confirm) ? $confirm : '""', - ); - } - - return sprintf( - '%s', - esc_attr($action), - $this->htmlAttributes($attributes), - esc_html($label), - ); - } - - /** @param list $packages */ - private function renderViews(array $packages): void - { - $counts = $this->counts($packages); - $views = [ - 'all' => __('All'), - 'active' => __('Active'), - 'inactive' => __('Inactive'), - 'mustuse' => __('Must-Use', 'sympress-kernel'), - 'plugins' => __('Plugins'), - 'themes' => __('Themes'), - ]; $currentView = $this->currentView(); - $items = []; - - foreach ($views as $view => $label) { - if (($counts[$view] ?? 0) < 1 && $view !== 'all') { - continue; - } - - $items[] = sprintf( - '
  • %4$s ' - . '(%5$s)
  • ', - esc_attr($view), - esc_url($this->viewUrl($view)), - $currentView === $view ? ' class="current" aria-current="page"' : '', - esc_html($label), - esc_html((string) ($counts[$view] ?? 0)), - ); - } - - echo '
      '; - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - echo implode(' | ', $items); - echo '
    '; - } - - private function renderNotice(): void - { $notice = $this->requestString(self::NOTICE_QUERY_VAR); - - if ($notice === '') { - return; - } - $message = $this->noticeMessage($notice, $this->requestString(self::MESSAGE_QUERY_VAR)); - $class = $notice === 'error' ? 'notice notice-error' : 'notice notice-success'; - printf( - '

    %s

    ', - esc_attr($class), - esc_html($message), + $this->view($currentView, $notice, $message)->render( + $packages, + $this->filterPackages($packages, $currentView), ); } @@ -649,361 +286,24 @@ private function bulkNoticeMessage( return $message; } - private function activate(PackageMetadata $package): ?\WP_Error - { - if ($package->isPlugin()) { - $this->loadPluginAdminFunctions(); - $result = activate_plugin($package->entry(), '', is_network_admin(), false); - - return $result instanceof \WP_Error ? $result : null; - } - - if ($package->isTheme()) { - switch_theme($package->entry()); - - return null; - } - - return new \WP_Error( - 'kernel_package_not_activatable', - __('This package type cannot be activated here.', 'sympress-kernel'), - ); - } - - private function deactivate(PackageMetadata $package): ?\WP_Error - { - if ($package->isPlugin()) { - $this->loadPluginAdminFunctions(); - deactivate_plugins($package->entry(), false, is_network_admin()); - - return null; - } - - if ($package->isTheme()) { - return $this->deactivateTheme($package); - } - - return new \WP_Error( - 'kernel_package_not_deactivatable', - __('This package type cannot be deactivated here.', 'sympress-kernel'), - ); - } - - private function deactivateTheme(PackageMetadata $package): ?\WP_Error - { - $fallback = $this->fallbackTheme($package->entry()); - - if ($fallback === '') { - return new \WP_Error( - 'kernel_package_theme_without_fallback', - __( - 'Install another theme before deactivating this theme package.', - 'sympress-kernel', - ), - ); - } - - switch_theme($fallback); - - return null; - } - - private function fallbackTheme(string $currentTheme): string - { - $defaultTheme = defined('WP_DEFAULT_THEME') ? (string) WP_DEFAULT_THEME : ''; - - if ($this->themeExists($defaultTheme) && $defaultTheme !== $currentTheme) { - return $defaultTheme; - } - - if (!function_exists('wp_get_themes')) { - return ''; - } - - foreach (wp_get_themes() as $stylesheet => $theme) { - if (!is_string($stylesheet) || $stylesheet === $currentTheme) { - continue; - } - - if (!$theme->exists()) { - continue; - } - - return $stylesheet; - } - - return ''; - } - - private function themeExists(string $stylesheet): bool - { - if ($stylesheet === '' || !function_exists('wp_get_theme')) { - return false; - } - - return wp_get_theme($stylesheet)->exists(); - } - - private function delete(PackageMetadata $package): ?\WP_Error - { - if ($package->active()) { - return new \WP_Error( - 'kernel_package_active_delete', - __('Deactivate the package before deleting it.', 'sympress-kernel'), - ); - } - - if (is_link($package->path())) { - return $this->deleteSymlinkPackage($package); - } - - if ($package->isPlugin()) { - $this->loadPluginAdminFunctions(); - $result = delete_plugins([$package->entry()]); - - return $this->deleteResultToError($result); - } - - if ($package->isTheme()) { - $this->loadThemeAdminFunctions(); - $result = delete_theme($package->entry(), $this->pageUrl()); - - return $this->deleteResultToError($result); - } - - return new \WP_Error( - 'kernel_package_not_deletable', - __('This package type cannot be deleted here.', 'sympress-kernel'), - ); - } - - private function deleteSymlinkPackage(PackageMetadata $package): ?\WP_Error - { - if (!$this->isManagedSymlinkPackage($package)) { - return new \WP_Error( - 'kernel_package_unmanaged_symlink', - __('Only managed WordPress package symlinks can be deleted here.', 'sympress-kernel'), - ); - } - - $this->beforeSymlinkDelete($package); - $deleted = @unlink($package->path()); - $this->afterSymlinkDelete($package, $deleted); - - if (!$deleted) { - return new \WP_Error( - 'kernel_package_delete_failed', - __('The package symlink could not be deleted.', 'sympress-kernel'), - ); - } - - if ($package->isPlugin() && function_exists('wp_clean_plugins_cache')) { - wp_clean_plugins_cache(true); - } - - if ($package->isTheme() && function_exists('wp_clean_themes_cache')) { - wp_clean_themes_cache(true); - } - - return null; - } - - private function beforeSymlinkDelete(PackageMetadata $package): void - { - if ($package->isPlugin()) { - $this->loadPluginAdminFunctions(); - - if (is_uninstallable_plugin($package->entry())) { - uninstall_plugin($package->entry()); - } - - if (function_exists('do_action')) { - do_action('delete_plugin', $package->entry()); - } - } - - if (!$package->isTheme()) { - return; - } - - if (!function_exists('do_action')) { - return; - } - - do_action('delete_theme', $package->entry()); - } - - private function afterSymlinkDelete(PackageMetadata $package, bool $deleted): void - { - if ($package->isPlugin() && function_exists('do_action')) { - do_action('deleted_plugin', $package->entry(), $deleted); - } - - if (!$package->isTheme() || !function_exists('do_action')) { - return; - } - - do_action('deleted_theme', $package->entry(), $deleted); - } - - private function deleteResultToError(mixed $result): ?\WP_Error - { - if ($result === true) { - return null; - } - - if ($result instanceof \WP_Error) { - return $result; - } - - return new \WP_Error( - 'kernel_package_delete_failed', - __('The package could not be deleted.', 'sympress-kernel'), - ); - } - - private function canRun(string $action, PackageMetadata $package): bool - { - if (!$this->fileModificationsAllowed($package)) { - return false; - } - - if ($action === 'delete') { - if ($package->isTheme()) { - return current_user_can('delete_themes'); - } - - return $package->isPlugin() && current_user_can('delete_plugins'); - } - - if ($action === 'deactivate') { - if ($package->isTheme()) { - return current_user_can('switch_themes'); - } - - return $package->isPlugin() && current_user_can('activate_plugins'); - } - - if ($action === 'activate' && $package->isTheme()) { - return current_user_can('switch_themes'); - } - - return $action === 'activate' - && $package->isPlugin() - && current_user_can('activate_plugins'); - } - - private function fileModificationsAllowed(PackageMetadata $package): bool - { - $context = $package->isTheme() ? 'themes' : 'plugins'; - - if (function_exists('wp_is_file_mod_allowed')) { - return wp_is_file_mod_allowed($context); - } - - return !defined('DISALLOW_FILE_MODS') || !constant('DISALLOW_FILE_MODS'); - } - - private function isManagedSymlinkPackage(PackageMetadata $package): bool - { - $root = $package->isTheme() ? $this->themeRootDirectory() : $this->pluginRootDirectory(); - $entryRoot = $this->entryRoot($package->entry()); - - if ($root === null || $entryRoot === '') { - return false; - } - - return $this->normalizePath($package->path()) === $this->normalizePath(sprintf('%s/%s', $root, $entryRoot)); - } - - private function entryRoot(string $entry): string - { - $parts = array_values( - array_filter( - explode('/', str_replace('\\', '/', $entry)), - static fn (string $part): bool => $part !== '', - ), - ); - - return $parts[0] ?? ''; - } - - private function pluginRootDirectory(): ?string - { - if (defined('WP_PLUGIN_DIR')) { - return (string) WP_PLUGIN_DIR; - } - - if (defined('WP_CONTENT_DIR')) { - return sprintf('%s/plugins', rtrim((string) WP_CONTENT_DIR, '/')); - } - - return null; - } - - private function themeRootDirectory(): ?string - { - if (function_exists('get_theme_root')) { - $themeRoot = get_theme_root(); - - if ($themeRoot !== '') { - return $themeRoot; - } - } - - if (defined('WP_CONTENT_DIR')) { - return sprintf('%s/themes', rtrim((string) WP_CONTENT_DIR, '/')); - } - - return null; - } - - private function normalizePath(string $path): string - { - return rtrim(str_replace('\\', '/', $path), '/'); - } - - private function hasBulkAction(PackageMetadata $package): bool - { - foreach (['activate', 'deactivate', 'delete'] as $action) { - if ($this->isActionAvailable($action, $package) && $this->canRun($action, $package)) { - return true; - } - } - - return false; - } - private function isKnownAction(string $action): bool { - return in_array($action, ['activate', 'deactivate', 'delete'], true); + return $this->actions->isKnownAction($action); } private function isActionAvailable(string $action, PackageMetadata $package): bool { - if ($package->isMustUsePlugin()) { - return false; - } - - return match ($action) { - 'activate' => !$package->active() && ($package->isPlugin() || $package->isTheme()), - 'deactivate' => $package->active() && ($package->isPlugin() || $package->isTheme()), - 'delete' => !$package->active() && ($package->isPlugin() || $package->isTheme()), - default => false, - }; + return $this->actions->isActionAvailable($action, $package); } private function actionUnavailableMessage(string $action, PackageMetadata $package): string { - if ($package->isMustUsePlugin()) { - return __('Must-use packages cannot be changed here.', 'sympress-kernel'); - } - - if ($action === 'delete' && $package->active()) { - return __('Deactivate the package before deleting it.', 'sympress-kernel'); - } + return $this->actions->actionUnavailableMessage($action, $package); + } - return __('This package action is not available.', 'sympress-kernel'); + private function canRun(string $action, PackageMetadata $package): bool + { + return $this->actions->canRun($action, $package); } /** @@ -1027,42 +327,6 @@ private function filterPackages(array $packages, string $view): array ); } - /** - * @param list $packages - * @return array - */ - private function counts(array $packages): array - { - $counts = [ - 'all' => count($packages), - 'active' => 0, - 'inactive' => 0, - 'mustuse' => 0, - 'plugins' => 0, - 'themes' => 0, - ]; - - foreach ($packages as $package) { - ++$counts[$package->active() ? 'active' : 'inactive']; - - if ($package->isMustUsePlugin()) { - ++$counts['mustuse']; - } - - if ($package->isPlugin()) { - ++$counts['plugins']; - } - - if (!$package->isTheme()) { - continue; - } - - ++$counts['themes']; - } - - return $counts; - } - private function currentView(): string { $view = $this->requestString(self::VIEW_QUERY_VAR); @@ -1218,85 +482,21 @@ private function postString(string $key): string return sanitize_text_field($value); } - private function description(PackageMetadata $package): string - { - if ($package->description() !== '') { - return $package->description(); - } - - return __('No description available.', 'sympress-kernel'); - } - - private function metaLine(PackageMetadata $package): string - { - $items = [ - sprintf( - /* translators: %s: Package type. */ - __('Type: %s', 'sympress-kernel'), - $package->typeLabel(), - ), - sprintf( - /* translators: %s: Composer package name. */ - __('Package: %s', 'sympress-kernel'), - $package->package(), - ), - sprintf( - /* translators: %s: Package entry file or theme stylesheet. */ - __('Entry: %s', 'sympress-kernel'), - $package->entry(), - ), - ]; - - if ($package->version() !== '') { - array_unshift( - $items, - sprintf( - /* translators: %s: Package version. */ - __('Version %s'), - $package->version(), - ), - ); - } - - return implode(' | ', $items); - } - - /** @param array $attributes */ - private function htmlAttributes(array $attributes): string - { - $parts = []; - - foreach ($attributes as $name => $value) { - $parts[] = sprintf('%s="%s"', esc_attr($name), esc_attr($value)); - } - - return implode(' ', $parts); - } - - private function loadPluginAdminFunctions(): void - { - if (!function_exists('activate_plugin') || !function_exists('delete_plugins')) { - require_once ABSPATH . 'wp-admin/includes/plugin.php'; - } - - if (function_exists('request_filesystem_credentials')) { - return; - } - - require_once ABSPATH . 'wp-admin/includes/file.php'; - } - - private function loadThemeAdminFunctions(): void + private function view(string $currentView, string $notice, string $message): PackageManagerPageView { - if (!function_exists('delete_theme')) { - require_once ABSPATH . 'wp-admin/includes/theme.php'; - } - - if (function_exists('request_filesystem_credentials')) { - return; - } - - require_once ABSPATH . 'wp-admin/includes/file.php'; + return new PackageManagerPageView( + self::SLUG, + self::VIEW_QUERY_VAR, + self::BULK_NONCE_ACTION, + $currentView, + $notice, + $message, + $this->pageUrl($this->currentViewArgs()), + fn (string $view): string => $this->viewUrl($view), + fn (string $action, PackageMetadata $package): string => $this->actionUrl($action, $package), + fn (string $action, PackageMetadata $package): bool => $this->isActionAvailable($action, $package), + fn (string $action, PackageMetadata $package): bool => $this->canRun($action, $package), + ); } private function permissionDenied(): never diff --git a/src/Admin/PackageManagerPageView.php b/src/Admin/PackageManagerPageView.php new file mode 100644 index 0000000..d54894c --- /dev/null +++ b/src/Admin/PackageManagerPageView.php @@ -0,0 +1,513 @@ + $packages + * @param list $visiblePackages + */ + public function render(array $packages, array $visiblePackages): void + { + ?> +
    +

    + +

    +
    + + renderNotice(); ?> + renderViews($packages); ?> + +
    + + currentView !== 'all') : ?> + + + bulkNonceAction); ?> + renderTableNav($visiblePackages, 'top'); ?> + renderTable($visiblePackages); ?> + renderTableNav($visiblePackages, 'bottom'); ?> +
    +
    + $packages */ + private function renderTableNav(array $packages, string $which): void + { + ?> +
    + renderBulkActions($which); ?> +
    + + itemCountLabel(count($packages))); ?> + +
    +
    +
    + +
    + + + +
    + $packages */ + private function renderTable(array $packages): void + { + ?> + + + renderTableHeader(); ?> + + + + + + + + + + renderRow($package); ?> + + + + renderTableHeader('cb-select-all-2'); ?> + +
    + +
    + + + + renderSelectAllCheckbox($checkboxId); ?> + + + + + + + + + + + + + + + active() ? 'active' : 'inactive'; + + ?> + + + renderRowCheckbox($package); ?> + + + name()); ?> + renderRowActions($package); ?> + + + +
    +

    description($package)); ?>

    +
    +
    + metaLine($package)); ?> +
    + + + typeLabel()); ?> + + + statusLabel()); ?> + + + + + + hasBulkAction($package)) { + echo ' '; + + return; + } + + $id = sprintf('package-%s', sanitize_html_class($package->package())); + + ?> + + + rowActions($package); + + if ($actions === []) { + return; + } + + echo '
    '; + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo implode(' | ', $actions); + echo '
    '; + } + + /** @return list */ + private function rowActions(PackageMetadata $package): array + { + if ($package->isMustUsePlugin()) { + return [ + sprintf( + '%s', + esc_html__('Must-use', 'sympress-kernel'), + ), + ]; + } + + $actions = []; + + if ($this->isActionAvailable('deactivate', $package)) { + $actions[] = $this->actionLink('deactivate', $package, __('Deactivate')); + } + + if ($this->isActionAvailable('activate', $package)) { + $actions[] = $this->actionLink('activate', $package, __('Activate')); + } + + if ($this->isActionAvailable('delete', $package)) { + $actions[] = $this->actionLink('delete', $package, __('Delete'), 'delete'); + } + + return array_values(array_filter($actions)); + } + + private function actionLink( + string $action, + PackageMetadata $package, + string $label, + string $class = '', + ): string { + + if (!$this->canRun($action, $package)) { + return ''; + } + + $attributes = [ + 'href' => esc_url($this->actionUrl($action, $package)), + ]; + + if ($class !== '') { + $attributes['class'] = $class; + } + + if ($action === 'delete') { + $confirm = wp_json_encode( + __('Are you sure you want to delete this package?', 'sympress-kernel'), + ); + $attributes['onclick'] = sprintf( + 'return confirm(%s);', + is_string($confirm) ? $confirm : '""', + ); + } + + return sprintf( + '%s', + esc_attr($action), + $this->htmlAttributes($attributes), + esc_html($label), + ); + } + + /** @param list $packages */ + private function renderViews(array $packages): void + { + $counts = $this->counts($packages); + $views = [ + 'all' => __('All'), + 'active' => __('Active'), + 'inactive' => __('Inactive'), + 'mustuse' => __('Must-Use', 'sympress-kernel'), + 'plugins' => __('Plugins'), + 'themes' => __('Themes'), + ]; + $items = []; + + foreach ($views as $view => $label) { + if (($counts[$view] ?? 0) < 1 && $view !== 'all') { + continue; + } + + $items[] = sprintf( + '
  • %4$s ' + . '(%5$s)
  • ', + esc_attr($view), + esc_url($this->viewUrl($view)), + $this->currentView === $view ? ' class="current" aria-current="page"' : '', + esc_html($label), + esc_html((string) ($counts[$view] ?? 0)), + ); + } + + echo '
      '; + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo implode(' | ', $items); + echo '
    '; + } + + private function renderNotice(): void + { + if ($this->notice === '') { + return; + } + + $class = $this->notice === 'error' ? 'notice notice-error' : 'notice notice-success'; + + printf( + '

    %s

    ', + esc_attr($class), + esc_html($this->noticeMessage), + ); + } + + private function hasBulkAction(PackageMetadata $package): bool + { + foreach (['activate', 'deactivate', 'delete'] as $action) { + if ($this->isActionAvailable($action, $package) && $this->canRun($action, $package)) { + return true; + } + } + + return false; + } + + private function isActionAvailable(string $action, PackageMetadata $package): bool + { + return ($this->isActionAvailable)($action, $package); + } + + private function canRun(string $action, PackageMetadata $package): bool + { + return ($this->canRun)($action, $package); + } + + private function actionUrl(string $action, PackageMetadata $package): string + { + return ($this->actionUrl)($action, $package); + } + + private function viewUrl(string $view): string + { + return ($this->viewUrl)($view); + } + + private function description(PackageMetadata $package): string + { + if ($package->description() !== '') { + return $package->description(); + } + + return __('No description available.', 'sympress-kernel'); + } + + private function metaLine(PackageMetadata $package): string + { + $items = [ + sprintf( + /* translators: %s: Package type. */ + __('Type: %s', 'sympress-kernel'), + $package->typeLabel(), + ), + sprintf( + /* translators: %s: Composer package name. */ + __('Package: %s', 'sympress-kernel'), + $package->package(), + ), + sprintf( + /* translators: %s: Package entry file or theme stylesheet. */ + __('Entry: %s', 'sympress-kernel'), + $package->entry(), + ), + ]; + + if ($package->version() !== '') { + array_unshift( + $items, + sprintf( + /* translators: %s: Package version. */ + __('Version %s'), + $package->version(), + ), + ); + } + + return implode(' | ', $items); + } + + /** + * @param list $packages + * @return array + */ + private function counts(array $packages): array + { + $counts = [ + 'all' => count($packages), + 'active' => 0, + 'inactive' => 0, + 'mustuse' => 0, + 'plugins' => 0, + 'themes' => 0, + ]; + + foreach ($packages as $package) { + ++$counts[$package->active() ? 'active' : 'inactive']; + + if ($package->isMustUsePlugin()) { + ++$counts['mustuse']; + } + + if ($package->isPlugin()) { + ++$counts['plugins']; + } + + if (!$package->isTheme()) { + continue; + } + + ++$counts['themes']; + } + + return $counts; + } + + /** @param array $attributes */ + private function htmlAttributes(array $attributes): string + { + $parts = []; + + foreach ($attributes as $name => $value) { + $parts[] = sprintf('%s="%s"', esc_attr($name), esc_attr($value)); + } + + return implode(' ', $parts); + } +} diff --git a/src/App.php b/src/App.php index dbb8bc8..4dd6e0f 100644 --- a/src/App.php +++ b/src/App.php @@ -4,7 +4,6 @@ namespace SymPress\Kernel; -use SymPress\Kernel\Bundle\BundleRegistry; use SymPress\Kernel\Hook\HookLoader; use SymPress\Kernel\Kernel\KernelInterface; use SymPress\Kernel\Kernel\SiteKernel; @@ -24,9 +23,9 @@ final class App private static ?self $app = null; private ?Container $container = null; - private ?BundleRegistry $bundles = null; private bool $booted = false; private bool $booting = false; + private ?bool $debugEnabled = null; private function __construct( private readonly KernelInterface $kernel, @@ -96,11 +95,17 @@ public static function handleThrowable(\Throwable $throwable): void public function enableDebug(): self { + $this->debugEnabled = true; + $this->applyDebugState(); + return $this; } public function disableDebug(): self { + $this->debugEnabled = false; + $this->applyDebugState(); + return $this; } @@ -122,43 +127,43 @@ public function boot(): void $this->booting = true; self::dispatchAction(self::ACTION_BOOTING, $this->kernel); - $this->initializeContainer(); - $this->bundles = $this->kernel->discoverBundles(); + $container = $this->initializeContainer(); + $bundles = $this->kernel->discoverBundles(); - if (!$this->kernel->tryUseRuntimeContainer($this->container, $this->bundles)) { - self::dispatchAction(self::ACTION_BEFORE_CONTAINER_BUILD, $this->container, $this->bundles); + if (!$this->kernel->tryUseRuntimeContainer($container, $bundles)) { + self::dispatchAction(self::ACTION_BEFORE_CONTAINER_BUILD, $container, $bundles); self::dispatchAction(self::LEGACY_ACTION_BEFORE_CONTAINER_BUILD); $loadedConfigFiles = $this->kernel->configureContainer( - $this->container->builder(), - $this->container, - $this->bundles, + $container->builder(), + $container, + $bundles, ); self::dispatchAction( self::ACTION_CONTAINER_CONFIGURED, - $this->container, - $this->bundles, + $container, + $bundles, $loadedConfigFiles, ); $this->kernel->createRuntimeContainer( - $this->container, - $this->bundles, + $container, + $bundles, $loadedConfigFiles, ); } - self::dispatchAction(self::ACTION_CONTAINER_READY, $this->container, $this->bundles); - self::dispatchAction(self::LEGACY_ACTION_CONTAINER_READY, $this->container); + self::dispatchAction(self::ACTION_CONTAINER_READY, $container, $bundles); + self::dispatchAction(self::LEGACY_ACTION_CONTAINER_READY, $container); + $this->applyDebugState(); $this->registerHooks(); - $this->kernel->boot($this->container, $this->bundles); + $this->kernel->boot($container, $bundles); $this->booted = true; $this->booting = false; - self::dispatchAction(self::ACTION_BOOTED, $this->container, $this->bundles); - self::dispatchAction(self::LEGACY_ACTION_CONTAINER_LOADED, $this->container); + self::dispatchAction(self::ACTION_BOOTED, $container, $bundles); + self::dispatchAction(self::LEGACY_ACTION_CONTAINER_LOADED, $container); } catch (\Throwable $throwable) { $this->booted = false; $this->booting = false; $this->container = null; - $this->bundles = null; self::handleThrowable($throwable); throw $throwable; } @@ -173,9 +178,9 @@ public function resolve(string $id, mixed $default = null): mixed throw new \DomainException("Can't resolve from an uninitialised application."); } - $this->initializeContainer(); + $container = $this->initializeContainer(); - if (!$this->container->has($id)) { + if (!$container->has($id)) { if (function_exists('do_action')) { do_action( self::ACTION_ERROR, @@ -186,7 +191,7 @@ public function resolve(string $id, mixed $default = null): mixed return $default; } - $value = $this->container->get($id); + $value = $container->get($id); } catch (\Throwable $throwable) { self::handleThrowable($throwable); } @@ -194,14 +199,17 @@ public function resolve(string $id, mixed $default = null): mixed return $value; } - private function initializeContainer(): void + private function initializeContainer(): Container { if ($this->container instanceof Container) { - return; + return $this->container; } - $this->container = $this->kernel->createContainer(); - $this->container->setApp($this); + $container = $this->kernel->createContainer(); + $container->setApp($this); + $this->container = $container; + + return $container; } private function registerHooks(): void @@ -219,6 +227,26 @@ private function registerHooks(): void $hookLoader->register(); } + private function applyDebugState(): void + { + if ( + !$this->container instanceof Container + || $this->debugEnabled === null + || !$this->container->has('profiler') + ) { + return; + } + + $profiler = $this->container->get('profiler'); + $method = $this->debugEnabled ? 'enable' : 'disable'; + + if (!is_object($profiler) || !method_exists($profiler, $method)) { + return; + } + + $profiler->{$method}(); + } + private static function defaultProjectDir(): string { $directory = __DIR__; @@ -257,7 +285,7 @@ private static function isProjectComposerFile(string $composerFile): bool private static function dispatchAction(string $action, mixed ...$arguments): void { - if (!function_exists('do_action')) { + if ($action === '' || !function_exists('do_action')) { return; } diff --git a/src/Bundle/AbstractBundle.php b/src/Bundle/AbstractBundle.php index 84c138c..a7db024 100644 --- a/src/Bundle/AbstractBundle.php +++ b/src/Bundle/AbstractBundle.php @@ -191,7 +191,6 @@ public function getContainerExtension(): ?ExtensionInterface ); } - // @phpstan-ignore new.internalClass, method.internalClass $this->extension = new BundleExtension($this, $this->extensionAlias); return $this->extension; diff --git a/src/Bundle/BundleInterface.php b/src/Bundle/BundleInterface.php index d204484..32e9ac3 100644 --- a/src/Bundle/BundleInterface.php +++ b/src/Bundle/BundleInterface.php @@ -5,7 +5,7 @@ namespace SymPress\Kernel\Bundle; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\Bundle\BundleInterface as SymfonyBundleInterface; +use Symfony\Component\DependencyInjection\Kernel\BundleInterface as SymfonyBundleInterface; interface BundleInterface extends SymfonyBundleInterface { diff --git a/src/Console/ContainerDumpCommand.php b/src/Console/ContainerDumpCommand.php index 93172af..3586d5c 100644 --- a/src/Console/ContainerDumpCommand.php +++ b/src/Console/ContainerDumpCommand.php @@ -33,7 +33,8 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $format = strtolower((string) $input->getOption('format')); + $format = $input->getOption('format'); + $format = is_string($format) ? strtolower($format) : 'yaml'; $builder = $this->container->builder(); if ($format === 'yaml') { diff --git a/src/Console/DebugContainerCommand.php b/src/Console/DebugContainerCommand.php index 1b56e74..8d620da 100644 --- a/src/Console/DebugContainerCommand.php +++ b/src/Console/DebugContainerCommand.php @@ -40,7 +40,8 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $search = strtolower((string) $input->getArgument('search')); + $searchArgument = $input->getArgument('search'); + $search = is_string($searchArgument) ? strtolower($searchArgument) : ''; $items = $this->items($input); foreach ($items as $item) { @@ -91,7 +92,7 @@ private function serviceIds(): array $runtimeContainer instanceof SymfonyContainerInterface && method_exists($runtimeContainer, 'getServiceIds') ) { - $ids = $runtimeContainer->getServiceIds(); + $ids = $this->stringList($runtimeContainer->getServiceIds()); sort($ids); return $ids; @@ -120,6 +121,21 @@ private function taggedServices(string $tag, bool $types, bool $showArguments): ); } + /** @return list */ + private function stringList(mixed $values): array + { + if (!is_array($values)) { + return []; + } + + return array_values( + array_filter( + $values, + static fn (mixed $value): bool => is_string($value), + ), + ); + } + /** @return list */ private function describedServices(bool $showArguments): array { diff --git a/src/Container.php b/src/Container.php index 86d49fa..a9655e0 100644 --- a/src/Container.php +++ b/src/Container.php @@ -24,6 +24,8 @@ final class Container implements SymfonyContainerInterface private ?PsrContainerInterface $runtimeContainer = null; private ?App $app = null; private ?KernelInterface $kernel = null; + private SiteConfig $config; + private WpContext $context; /** @var array */ private array $containers; @@ -38,13 +40,10 @@ public function __construct( $this->config = $config ?? new EnvConfig(); $this->context = $context ?? WpContext::new()->force(WpContext::CORE); $this->builder = $builder ?? new ContainerBuilder(); - $this->containers = $containers; + $this->containers = array_values($containers); $this->bootstrapBuilder(); } - private SiteConfig $config; - private WpContext $context; - public function __clone(): void { $this->runtimeContainer = null; diff --git a/src/Discovery/BundleDiscovery.php b/src/Discovery/BundleDiscovery.php index 68c6db7..0f0c664 100644 --- a/src/Discovery/BundleDiscovery.php +++ b/src/Discovery/BundleDiscovery.php @@ -127,10 +127,10 @@ private function composerBundle(string $packageName): ?BundleMetadata } $metadata = $this->composerMetadata($composerFile); - $kernel = $metadata['extra']['kernel'] ?? null; - $bundleClass = is_array($kernel) ? (string) ($kernel['bundle'] ?? '') : ''; - $entry = is_array($kernel) ? (string) ($kernel['entry'] ?? '') : ''; - $type = (string) ($metadata['type'] ?? ''); + $kernel = $this->kernelMetadata($metadata); + $bundleClass = $this->stringValue($kernel['bundle'] ?? null); + $entry = $this->stringValue($kernel['entry'] ?? null); + $type = $this->stringValue($metadata['type'] ?? null); if ($bundleClass === '' || $entry === '' || $type === '') { return null; @@ -211,7 +211,7 @@ private function configuredBundle(mixed $key, mixed $value, string $source): ?Bu } $bundleClass = is_string($value) ? $value : (is_string($key) ? $key : ''); - $environments = is_array($value) ? $value : ['all' => true]; + $environments = is_array($value) ? $this->stringKeyMap($value) : ['all' => true]; if ($bundleClass === '' || !$this->shouldLoadConfiguredBundle($environments)) { return null; @@ -458,9 +458,9 @@ private function requirementsActive(array $requirements): bool } $metadata = $this->composerMetadata($composerFile); - $kernel = $metadata['extra']['kernel'] ?? null; - $entry = is_array($kernel) ? (string) ($kernel['entry'] ?? '') : ''; - $type = (string) ($metadata['type'] ?? ''); + $kernel = $this->kernelMetadata($metadata); + $entry = $this->stringValue($kernel['entry'] ?? null); + $type = $this->stringValue($metadata['type'] ?? null); if ($entry === '' || $type === '') { return false; @@ -516,7 +516,7 @@ private function composerMetadata(string $composerFile): array $decoded = json_decode($contents, true); - $this->metadata[$composerFile] = is_array($decoded) ? $decoded : []; + $this->metadata[$composerFile] = is_array($decoded) ? $this->stringKeyMap($decoded) : []; return $this->metadata[$composerFile]; } @@ -576,7 +576,7 @@ private function hasKernelMetadata(string $package): bool $metadata = $this->composerMetadata($composerFile); - return is_array($metadata['extra']['kernel'] ?? null); + return $this->kernelMetadata($metadata) !== []; } private function isKernelPackage(string $package): bool @@ -614,4 +614,49 @@ private function normalizePackagePrefixes(array $packagePrefixes): array return array_values(array_unique($normalized)); } + + /** + * @param array $metadata + * @return array + */ + private function kernelMetadata(array $metadata): array + { + $extra = $metadata['extra'] ?? null; + + if (!is_array($extra)) { + return []; + } + + $kernel = $extra['kernel'] ?? null; + + return is_array($kernel) ? $this->stringKeyMap($kernel) : []; + } + + private function stringValue(mixed $value): string + { + if (is_scalar($value) || $value instanceof \Stringable) { + return (string) $value; + } + + return ''; + } + + /** + * @param array $values + * @return array + */ + private function stringKeyMap(array $values): array + { + $map = []; + + foreach ($values as $key => $value) { + if (!is_string($key)) { + continue; + } + + $map[$key] = $value; + } + + return $map; + } } diff --git a/src/EnvConfig.php b/src/EnvConfig.php index 66ef898..62441a8 100644 --- a/src/EnvConfig.php +++ b/src/EnvConfig.php @@ -144,12 +144,16 @@ public function env(): string ?? $this->readEnvVarOrConstant('VIP_GO_APP_ENVIRONMENT') ?? $this->readEnvVarOrConstant('VIP_GO_ENV'); - if ($env !== null) { + if (is_scalar($env) || $env instanceof \Stringable) { return $this->normalizeEnv((string) $env, false); } if (function_exists('is_wpe')) { - $env = (int) is_wpe() > 0 ? self::PRODUCTION : self::STAGING; + $isWpe = is_wpe(); + $env = (is_bool($isWpe) && $isWpe) + || (is_numeric($isWpe) && (int) $isWpe > 0) + ? self::PRODUCTION + : self::STAGING; $this->env = $this->normalizeEnv($env, true); return $this->env; diff --git a/src/Hook/HookCompilerPass.php b/src/Hook/HookCompilerPass.php index bb69db2..d899e31 100644 --- a/src/Hook/HookCompilerPass.php +++ b/src/Hook/HookCompilerPass.php @@ -29,6 +29,11 @@ public function process(ContainerBuilder $container): void $serviceMap[$id] = new Reference($id); foreach ($definition->getTag(HookLoader::TAG) as $attributes) { + if (!is_array($attributes)) { + continue; + } + + $attributes = $this->stringKeyMap($attributes); $method = $this->optionalStringAttribute($attributes, 'method', '__invoke'); $this->validateHookMethod($container, $id, $definition->getClass(), $method); @@ -174,6 +179,25 @@ private function validateHookMethod( ); } + /** + * @param array $values + * @return array + */ + private function stringKeyMap(array $values): array + { + $map = []; + + foreach ($values as $key => $value) { + if (!is_string($key)) { + continue; + } + + $map[$key] = $value; + } + + return $map; + } + private function reflectHookMethod( ContainerBuilder $container, string $serviceId, diff --git a/src/Kernel/AbstractKernel.php b/src/Kernel/AbstractKernel.php index 62571e1..301d5b5 100644 --- a/src/Kernel/AbstractKernel.php +++ b/src/Kernel/AbstractKernel.php @@ -4,76 +4,32 @@ namespace SymPress\Kernel\Kernel; -use Psr\Container\ContainerInterface as PsrContainerInterface; -use SymPress\Kernel\App; -use SymPress\Kernel\Attribute\AsHook; -use SymPress\Kernel\Attribute\Route; use SymPress\Kernel\Bundle\BundleInterface; use SymPress\Kernel\Bundle\BundleRegistry; -use SymPress\Kernel\Console\ConsoleApplicationFactory; -use SymPress\Kernel\Console\WpCliConsoleBridge; use SymPress\Kernel\Container; use SymPress\Kernel\DependencyInjection\EnvironmentParameterLoader; use SymPress\Kernel\Discovery\BundleDiscovery; use SymPress\Kernel\EnvConfig; -use SymPress\Kernel\Hook\HookCompilerPass; -use SymPress\Kernel\Hook\HookLoader; use SymPress\Kernel\Resolver\ActivePackageResolver; -use SymPress\Kernel\Routing\RouteCompilerPass; use SymPress\Kernel\Routing\RouteLoader; use SymPress\Kernel\SiteConfig; -use SymPress\Kernel\Translation\TranslationLoader; use SymPress\Kernel\WpContext; -use Symfony\Component\Config\Resource\FileExistenceResource; use Symfony\Component\Config\Resource\FileResource; -use Symfony\Component\Config\Resource\SelfCheckingResourceChecker; -use Symfony\Component\Config\ResourceCheckerConfigCacheFactory; -use Symfony\Component\Config\ResourceCheckerInterface; use Symfony\Component\Config\Loader\DelegatingLoader; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\Config\Loader\LoaderResolver; -use Symfony\Component\DependencyInjection\Config\ContainerParametersResourceChecker; -use Symfony\Component\Console\Application; -use Symfony\Component\Console\Attribute\AsCommand; -use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass; -use Symfony\Component\DependencyInjection\Argument\IteratorArgument; -use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; -use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; -use Symfony\Component\DependencyInjection\ChildDefinition; -use Symfony\Component\DependencyInjection\Compiler\AddBehaviorDescribingTagsPass; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\ContainerInterface as SymfonyDiContainerInterface; -use Symfony\Component\DependencyInjection\Definition; -use Symfony\Component\DependencyInjection\Dumper\PhpDumper; -use Symfony\Component\DependencyInjection\Compiler\MergeExtensionConfigurationPass; use Symfony\Component\DependencyInjection\Compiler\PassConfig; -use Symfony\Component\DependencyInjection\Compiler\ResettableServicePass; -use Symfony\Component\DependencyInjection\EnvVarLoaderInterface; -use Symfony\Component\DependencyInjection\EnvVarProcessor; -use Symfony\Component\DependencyInjection\EnvVarProcessorInterface; -use Symfony\Component\DependencyInjection\Kernel\KernelInterface as DependencyInjectionKernelInterface; use Symfony\Component\DependencyInjection\Loader\ClosureLoader; use Symfony\Component\DependencyInjection\Loader\DirectoryLoader; use Symfony\Component\DependencyInjection\Loader\GlobFileLoader; use Symfony\Component\DependencyInjection\Loader\IniFileLoader; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; -use Symfony\Component\DependencyInjection\ParameterBag\ContainerBag; -use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; -use Symfony\Component\DependencyInjection\Reference; -use Symfony\Component\DependencyInjection\ReverseContainer; -use Symfony\Component\DependencyInjection\ServiceLocator; -use Symfony\Component\DependencyInjection\ServicesResetter; -use Symfony\Component\DependencyInjection\ServicesResetterInterface; -use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelInterface; -use Symfony\Component\HttpKernel\KernelInterface as SymfonyKernelInterface; -use Symfony\Contracts\Service\ResetInterface; -use Symfony\Contracts\Service\ServiceSubscriberInterface; abstract class AbstractKernel implements KernelInterface { @@ -87,8 +43,7 @@ abstract class AbstractKernel implements KernelInterface /** @var array */ protected array $bundles = []; - /** @var array */ - private array $preparedBuilders = []; + private ?CoreServiceRegistrar $coreServiceRegistrar = null; public function __construct( protected readonly string $projectDir, @@ -120,7 +75,7 @@ public function __clone() $this->container = null; $this->bundleRegistry = null; $this->bundles = []; - $this->preparedBuilders = []; + $this->coreServiceRegistrar = null; } /** @return array{project_dir: string, environment: string, debug: bool} */ @@ -169,54 +124,22 @@ public function getCharset(): string public function getCacheDir(): string { - $dir = $this->serverString('APP_CACHE_DIR'); - - if ($dir !== null) { - return sprintf('%s/kernel', $this->environmentDirectory($dir)); - } - - return sprintf('%s/var/cache/%s/kernel', $this->projectDir, $this->environment); + return $this->configuration()->cacheDir(); } public function getBuildDir(): string { - $dir = $this->serverString('APP_BUILD_DIR'); - - if ($dir !== null) { - return sprintf('%s/kernel', $this->environmentDirectory($dir)); - } - - return $this->getCacheDir(); + return $this->configuration()->buildDir(); } public function getShareDir(): ?string { - $dir = $this->serverNullableDirectory('APP_SHARE_DIR'); - - if ($dir !== null) { - return sprintf('%s/kernel', $this->environmentDirectory($dir)); - } - - if ($this->serverValueIsFalse('APP_SHARE_DIR')) { - return null; - } - - return $this->getCacheDir(); + return $this->configuration()->shareDir(); } public function getLogDir(): ?string { - $dir = $this->serverNullableDirectory('APP_LOG_DIR'); - - if ($dir !== null) { - return $this->environmentDirectory($dir); - } - - if ($this->serverValueIsFalse('APP_LOG_DIR')) { - return null; - } - - return sprintf('%s/var/log', $this->projectDir); + return $this->configuration()->logDir(); } public function getStartTime(): float @@ -339,7 +262,7 @@ public function discoverBundles(): BundleRegistry { $this->bundleRegistry = (new BundleDiscovery( new ActivePackageResolver(), - $this->packagePrefixes(), + $this->configuration()->packagePrefixes(), $this->projectDir, $this->environment, ))->discover(); @@ -376,13 +299,13 @@ public function configureContainer( $builder->setParameter('kernel.build_dir', $this->getBuildDir()); $builder->setParameter('kernel.share_dir', $this->getShareDir()); $builder->setParameter('kernel.logs_dir', $this->getLogDir()); - $builder->setParameter('kernel.package_prefixes', $this->packagePrefixes()); + $builder->setParameter('kernel.package_prefixes', $this->configuration()->packagePrefixes()); $builder->setParameter('kernel.translation_paths', $bundles->translationDirectories()); $builder->setParameter('kernel.bundles', $this->bundleClasses($bundles)); $builder->setParameter('kernel.bundles_metadata', $this->bundleMetadata($bundles)); $builder->setParameter('kernel.container_class', 'KernelContainer'); $builder->setParameter('.kernel.config_dir', sprintf('%s/config', $this->projectDir)); - $builder->setParameter('.container.known_envs', $this->knownEnvironments()); + $builder->setParameter('.container.known_envs', $this->configuration()->knownEnvironments()); $this->loadEnvironmentVariablesAsParameters($builder); foreach ($bundles->all() as $bundle) { @@ -401,8 +324,8 @@ public function configureContainer( $loaded = []; - foreach ($this->configDirectories($bundles) as $configDir) { - foreach ($this->resolveConfigFiles($configDir) as $file) { + foreach ($this->configuration()->configDirectories($bundles) as $configDir) { + foreach ($this->configuration()->resolveConfigFiles($configDir) as $file) { $this->loadConfigFile($builder, $file); $loaded[] = $file; } @@ -419,28 +342,10 @@ public function build(ContainerBuilder $builder): void public function tryUseRuntimeContainer(Container $container, BundleRegistry $bundles): bool { - if ($this->tracksSourceChanges()) { - return false; - } - - $cacheDir = $this->getCacheDir(); - $metaFile = sprintf('%s/meta.php', $cacheDir); - - if (!is_file($metaFile)) { - return false; - } - - $metadata = require $metaFile; - - if (!is_array($metadata)) { - return false; - } - - return $this->useCachedRuntimeContainer( + return $this->containerCacheManager()->tryUseRuntimeContainer( $container, - $cacheDir, - $metadata, - $this->fingerprint($bundles, $this->runtimeConfigFiles($bundles)), + $bundles, + $this->configuration()->runtimeConfigFiles($bundles), ); } @@ -449,85 +354,25 @@ public function createRuntimeContainer( BundleRegistry $bundles, array $configFiles, ): void { + $this->containerCacheManager()->createRuntimeContainer($container, $bundles, $configFiles); + } - $cacheDir = $this->getCacheDir(); - $filesystem = new Filesystem(); - $filesystem->mkdir($cacheDir); - $metaFile = sprintf('%s/meta.php', $cacheDir); - $lockFile = sprintf('%s/container.lock', $cacheDir); - $fingerprint = $this->fingerprint($bundles, $configFiles); - $lock = fopen($lockFile, 'c+'); - - if (!is_resource($lock)) { - throw new \RuntimeException(sprintf('Unable to create cache lock "%s".', $lockFile)); - } - - try { - if (!flock($lock, LOCK_EX)) { - throw new \RuntimeException(sprintf('Unable to lock cache file "%s".', $lockFile)); - } - - clearstatcache(true, $metaFile); - - $metadata = is_file($metaFile) ? require $metaFile : null; - - if ( - is_array($metadata) - && $this->useCachedRuntimeContainer($container, $cacheDir, $metadata, $fingerprint) - ) { - return; - } - - $sourceResources = $this->sourceResourceManifest($bundles); - $sourceFingerprint = $this->sourceResourceFingerprint($sourceResources); - $cacheKey = substr(hash('sha256', "{$fingerprint}|{$sourceFingerprint}"), 0, 16); - $containerFile = sprintf('%s/container_%s.php', $cacheDir, $cacheKey); - clearstatcache(true, $containerFile); - - $class = sprintf('KernelContainer_%s', $cacheKey); - $runtime = $this->createRuntimeBuilder($container, $class); - $runtime->compile(true); - $configResources = $this->configResourceManifest($runtime, $configFiles); - $dumper = new PhpDumper($runtime); - $filesystem->dumpFile( - $containerFile, - $dumper->dump( - [ - 'class' => $class, - 'debug' => $this->debug, - 'file' => $containerFile, - 'build_time' => $this->containerBuildTime($runtime), - 'inline_class_loader' => $this->debug, - ], - ), - ); - $filesystem->dumpFile( - $metaFile, - sprintf( - " $fingerprint, - 'config_resources' => $configResources, - 'source_fingerprint' => $sourceFingerprint, - 'source_resources' => $sourceResources, - 'class' => $class, - 'file' => basename($containerFile), - ], - true, - ), - ), - ); - - if (!class_exists($class, false)) { - require $containerFile; - } + private function containerCacheManager(): ContainerCacheManager + { + return new ContainerCacheManager( + $this->getCacheDir(), + $this->debug, + new ContainerResourceFingerprinter($this->projectDir, $this->environment, $this->debug), + ); + } - $container->useRuntimeContainer($this->newRuntimeContainer($class)); - } finally { - flock($lock, LOCK_UN); - fclose($lock); - } + private function configuration(): KernelConfigurationResolver + { + return new KernelConfigurationResolver( + $this->projectDir, + $this->environment, + $this->config, + ); } public function boot(?Container $container = null, ?BundleRegistry $bundles = null): void @@ -635,249 +480,6 @@ private function bundleMetadata(BundleRegistry $bundles): array return $metadataByName; } - /** @return list */ - private function knownEnvironments(): array - { - $known = [ - $this->environment, - 'all', - 'dev', - 'development', - 'local', - 'prod', - 'production', - 'stage', - 'staging', - 'test', - ]; - $bundlesDefinition = sprintf('%s/config/bundles.php', $this->projectDir); - - if (!is_file($bundlesDefinition)) { - return $this->normalizeKnownEnvironments($known); - } - - $configuration = require $bundlesDefinition; - - if (!is_array($configuration)) { - return $this->normalizeKnownEnvironments($known); - } - - foreach ($configuration as $envs) { - if (!is_array($envs)) { - continue; - } - - foreach (array_keys($envs) as $environment) { - if (!is_string($environment)) { - continue; - } - - $known[] = $environment; - } - } - - return $this->normalizeKnownEnvironments($known); - } - - /** - * @param list $environments - * @return list - */ - private function normalizeKnownEnvironments(array $environments): array - { - return array_values( - array_unique( - array_filter($environments, static fn (string $env): bool => $env !== ''), - ), - ); - } - - /** @return array */ - private function configDirectories(BundleRegistry $bundles): array - { - $directories = []; - $libraryDir = dirname(__DIR__, 2) . '/config'; - $siteDir = sprintf('%s/config', $this->projectDir); - - if (is_dir($libraryDir)) { - $directories[] = $libraryDir; - } - - foreach ($bundles->configDirectories() as $configDir) { - $directories[] = $configDir; - } - - if (is_dir($siteDir)) { - $directories[] = $siteDir; - } - - return array_values(array_unique($directories)); - } - - private function serverString(string $name): ?string - { - $value = $_SERVER[$name] ?? $_ENV[$name] ?? null; - - if (!is_scalar($value) && !$value instanceof \Stringable) { - return null; - } - - $value = trim((string) $value); - - return $value === '' ? null : $value; - } - - private function serverNullableDirectory(string $name): ?string - { - if ($this->serverValueIsFalse($name)) { - return null; - } - - return $this->serverString($name); - } - - private function serverValueIsFalse(string $name): bool - { - $value = $_SERVER[$name] ?? $_ENV[$name] ?? null; - - if ($value === null) { - return false; - } - - return filter_var($value, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE) === false; - } - - private function environmentDirectory(string $directory): string - { - if ($directory !== '' && in_array($directory[0], ['/', '\\'], true)) { - return sprintf('%s/%s', rtrim($directory, '/'), $this->environment); - } - - if ( - DIRECTORY_SEPARATOR === '\\' - && isset($directory[1]) - && $directory[1] === ':' - && preg_match('/^[A-Za-z]:/', $directory) === 1 - ) { - return sprintf('%s/%s', rtrim($directory, '/'), $this->environment); - } - - return sprintf('%s/%s/%s', $this->projectDir, trim($directory, '/'), $this->environment); - } - - /** @return list */ - private function packagePrefixes(): array - { - $configured = $this->config->get('KERNEL_PACKAGE_PREFIXES', null); - - if ($configured === null) { - $configured = $this->composerKernelPackagePrefixes(); - } - - return $this->normalizePackagePrefixes($configured); - } - - private function composerKernelPackagePrefixes(): mixed - { - $composerFile = sprintf('%s/composer.json', $this->projectDir); - - if (!is_file($composerFile)) { - return null; - } - - $contents = file_get_contents($composerFile); - - if (!is_string($contents) || $contents === '') { - return null; - } - - $metadata = json_decode($contents, true); - - if (!is_array($metadata)) { - return null; - } - - $kernel = $metadata['extra']['kernel'] ?? null; - - if (!is_array($kernel)) { - return null; - } - - return $kernel['package_prefixes'] ?? $kernel['packagePrefixes'] ?? null; - } - - /** @return list */ - private function normalizePackagePrefixes(mixed $packagePrefixes): array - { - if (is_string($packagePrefixes)) { - $packagePrefixes = preg_split('/[,\s]+/', $packagePrefixes) ?: []; - } - - if (!is_array($packagePrefixes)) { - return []; - } - - $normalized = []; - - foreach ($packagePrefixes as $prefix) { - if (!is_scalar($prefix) && !$prefix instanceof \Stringable) { - continue; - } - - $prefix = trim((string) $prefix); - - if ($prefix === '') { - continue; - } - - $normalized[] = str_ends_with($prefix, '/') ? $prefix : "{$prefix}/"; - } - - return array_values(array_unique($normalized)); - } - - /** @return array */ - private function resolveConfigFiles(string $configDir): array - { - $files = []; - - foreach ($this->patterns($configDir) as $pattern) { - $matches = glob($pattern, GLOB_BRACE) ?: []; - sort($matches); - $files = [...$files, ...$matches]; - } - - return $files; - } - - /** @return array */ - private function runtimeConfigFiles(BundleRegistry $bundles): array - { - $files = []; - - foreach ($this->configDirectories($bundles) as $configDir) { - $files = [...$files, ...$this->resolveConfigFiles($configDir)]; - } - - return array_values(array_unique($files)); - } - - /** @return array */ - private function patterns(string $configDir): array - { - $env = $this->environment; - $extensions = '{php,yaml,yml,ini}'; - - return [ - sprintf('%s/packages/*.%s', $configDir, $extensions), - sprintf('%s/packages/%s/*.%s', $configDir, $env, $extensions), - sprintf('%s/services.%s', $configDir, $extensions), - sprintf('%s/services_%s.%s', $configDir, $env, $extensions), - sprintf('%s/wordpress.%s', $configDir, $extensions), - sprintf('%s/wordpress_%s.%s', $configDir, $env, $extensions), - ]; - } - private function loadConfigFile(ContainerBuilder $builder, string $file): void { $basename = basename($file); @@ -912,977 +514,21 @@ private function loadEnvironmentVariablesAsParameters(ContainerBuilder $builder) { (new EnvironmentParameterLoader())->load( $builder, - array_merge($_SERVER, $_ENV), - $this->config->get('KERNEL_ENV_PARAMETERS', null), - ); - } - - /** @param array $configFiles */ - private function fingerprint(BundleRegistry $bundles, array $configFiles): string - { - sort($configFiles); - - $parts = [ - $this->projectDir, - $this->environment, - (string) (int) $this->debug, - $this->deploymentFingerprint(), - $this->kernelFingerprint(), - ...$bundles->identityFingerprintParts(), - ]; - - foreach ($configFiles as $file) { - $parts[] = sprintf( - '%s:%s', - $file, - is_file($file) ? $this->fileFingerprint($file) : 'missing', - ); - } - - return hash('sha256', implode('|', $parts)); - } - - private function createRuntimeBuilder(Container $container, string $class): ContainerBuilder - { - $runtime = new ContainerBuilder(); - $this->copyExtensions($container->builder(), $runtime); - $runtime->merge($container->builder()); - $this->copyCompilerPasses($container->builder(), $runtime); - $runtime->setParameter('kernel.container_class', $class); - $runtime->getCompilerPassConfig()->setMergePass( - new MergeExtensionConfigurationPass($this->registeredExtensionAliases($runtime)), - ); - $runtime->addCompilerPass(new HookCompilerPass()); - $runtime->addCompilerPass(new RouteCompilerPass()); - $runtime->addCompilerPass(new AddConsoleCommandPass()); - $this->ensureSynthetic($runtime, Container::CONTAINER_ID, Container::class); - $this->ensureSynthetic($runtime, Container::CONFIG_ID, SiteConfig::class); - $this->ensureSynthetic($runtime, Container::CONTEXT_ID, WpContext::class); - $this->ensureSynthetic($runtime, Container::KERNEL_ID, KernelInterface::class); - $this->ensureSynthetic($runtime, Container::APP_ID, App::class); - - $runtime->setAlias(Container::class, Container::CONTAINER_ID)->setPublic(true); - $runtime->setAlias(PsrContainerInterface::class, Container::CONTAINER_ID)->setPublic(true); - $runtime->setAlias(SiteConfig::class, Container::CONFIG_ID)->setPublic(true); - $runtime->setAlias(WpContext::class, Container::CONTEXT_ID)->setPublic(true); - $runtime->setAlias(KernelInterface::class, Container::KERNEL_ID)->setPublic(true); - $runtime->setAlias(App::class, Container::APP_ID)->setPublic(true); - - return $runtime; - } - - /** @return list */ - private function registeredExtensionAliases(ContainerBuilder $builder): array - { - $aliases = []; - - foreach ($builder->getExtensions() as $extension) { - $aliases[] = $extension->getAlias(); - } - - return array_values(array_unique($aliases)); - } - - private function copyExtensions(ContainerBuilder $source, ContainerBuilder $target): void - { - foreach ($source->getExtensions() as $extension) { - $target->registerExtension($extension); - } - } - - private function copyCompilerPasses(ContainerBuilder $source, ContainerBuilder $target): void - { - $sourcePasses = $source->getCompilerPassConfig(); - $targetPasses = $target->getCompilerPassConfig(); - - $targetPasses->setBeforeOptimizationPasses($sourcePasses->getBeforeOptimizationPasses()); - $targetPasses->setOptimizationPasses($sourcePasses->getOptimizationPasses()); - $targetPasses->setBeforeRemovingPasses($sourcePasses->getBeforeRemovingPasses()); - $targetPasses->setRemovingPasses($sourcePasses->getRemovingPasses()); - $targetPasses->setAfterRemovingPasses($sourcePasses->getAfterRemovingPasses()); - } - - /** @param array $metadata */ - private function useCachedRuntimeContainer( - Container $container, - string $cacheDir, - array $metadata, - string $fingerprint, - ): bool { - - if ( - ($metadata['fingerprint'] ?? null) !== $fingerprint - || !is_string($metadata['class'] ?? null) - || !is_string($metadata['file'] ?? null) - ) { - return false; - } - - if (!$this->configResourcesAreFresh($metadata['config_resources'] ?? null)) { - return false; - } - - if ( - $this->shouldValidateCachedSourceResources() - && !$this->sourceResourcesAreFresh($metadata['source_resources'] ?? null) - ) { - return false; - } - - $cachedContainerFile = sprintf('%s/%s', $cacheDir, basename($metadata['file'])); - - if (!is_file($cachedContainerFile)) { - return false; - } - - require_once $cachedContainerFile; - $class = $metadata['class']; - - if (!class_exists($class, false)) { - return false; - } - - $container->useRuntimeContainer($this->newRuntimeContainer($class)); - - return true; - } - - private function kernelFingerprint(): string - { - $packageDir = dirname(__DIR__, 2); - $composerFile = sprintf('%s/composer.json', $packageDir); - - return hash( - 'sha256', - implode( - '|', - [ - $packageDir, - sprintf('%s:%s', $composerFile, $this->fileFingerprint($composerFile)), - ], + array_merge( + $this->configuration()->stringKeyMap($_SERVER), + $this->configuration()->stringKeyMap($_ENV), ), + $this->config->get('KERNEL_ENV_PARAMETERS', null), ); } - private function tracksSourceChanges(): bool - { - return $this->debug && !$this->resourceTrackingDisabled(); - } - - private function resourceTrackingDisabled(): bool - { - $value = $_SERVER['SYMFONY_DISABLE_RESOURCE_TRACKING'] - ?? $_ENV['SYMFONY_DISABLE_RESOURCE_TRACKING'] - ?? null; - - if ($value === null) { - return false; - } - - if (is_array($value)) { - return $value !== []; - } - - return filter_var($value, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE) ?? ((string) $value !== ''); - } - - private function fileFingerprint(string $file): string - { - if (!is_file($file)) { - return 'missing'; - } - - if ($this->tracksSourceChanges()) { - return sha1_file($file); - } - - return $this->sourceFileMtime($file); - } - - private function deploymentFingerprint(): string - { - $buildId = defined('SYMPRESS_KERNEL_BUILD_ID') - ? (string) constant('SYMPRESS_KERNEL_BUILD_ID') - : getenv('SYMPRESS_KERNEL_BUILD_ID'); - - if (is_string($buildId) && $buildId !== '') { - return 'build:' . $buildId; - } - - $composerLock = sprintf('%s/composer.lock', $this->projectDir); - - if (is_file($composerLock)) { - return 'composer-lock:' . $this->fileFingerprint($composerLock); - } - - return 'build:implicit'; - } - - private function containerBuildTime(ContainerBuilder $builder): int + private function prepareBuilder(ContainerBuilder $builder): void { - if ($builder->hasParameter('kernel.container_build_time')) { - $buildTime = $builder->getParameter('kernel.container_build_time'); - - if (is_int($buildTime)) { - return $buildTime; - } - - if (is_string($buildTime) && ctype_digit($buildTime)) { - return (int) $buildTime; - } - } - - $sourceDateEpoch = $_SERVER['SOURCE_DATE_EPOCH'] ?? $_ENV['SOURCE_DATE_EPOCH'] ?? null; - - if (is_scalar($sourceDateEpoch) && filter_var($sourceDateEpoch, \FILTER_VALIDATE_INT) !== false) { - return (int) $sourceDateEpoch; - } - - return time(); + $this->coreServiceRegistrar()->prepare($builder, $this->build(...)); } - /** @return array */ - private function sourceResourceManifest(BundleRegistry $bundles): array + private function coreServiceRegistrar(): CoreServiceRegistrar { - $resources = []; - $this->collectSourceResources(dirname(__DIR__, 2) . '/src', $resources); - - foreach ($bundles->all() as $metadata) { - $this->collectSourceResources(sprintf('%s/src', rtrim($metadata->path(), '/')), $resources, 'php'); - - $bundleFile = (new \ReflectionObject($metadata->bundle()))->getFileName(); - - if (!is_string($bundleFile)) { - continue; - } - - $resources[$bundleFile] = $this->sourceFileMtime($bundleFile); - } - - ksort($resources); - - return $resources; - } - - /** - * @param array $resources - */ - private function collectSourceResources( - string $directory, - array &$resources, - ?string $fileExtension = null, - ): void { - - if (!is_dir($directory)) { - return; - } - - $resources[$directory] = $this->sourceDirectoryMtime($directory); - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::SELF_FIRST, - ); - - foreach ($iterator as $resource) { - if (!$resource instanceof \SplFileInfo) { - continue; - } - - $pathname = $resource->getPathname(); - - if ($resource->isDir()) { - $resources[$pathname] = $this->sourceDirectoryMtime($pathname); - - continue; - } - - if (!$resource->isFile()) { - continue; - } - - if ($fileExtension !== null && $resource->getExtension() !== $fileExtension) { - continue; - } - - $resources[$pathname] = $this->sourceFileMtime($pathname); - } - } - - private function sourceResourcesAreFresh(mixed $resources): bool - { - if (!is_array($resources) || $resources === []) { - return false; - } - - foreach ($resources as $path => $expected) { - if (!is_string($path) || !is_string($expected)) { - return false; - } - - $actual = str_starts_with($expected, 'dir:') - ? $this->sourceDirectoryMtime($path) - : $this->sourceFileMtime($path); - - if ($actual !== $expected) { - return false; - } - } - - return true; - } - - /** - * @param array $configFiles - * @return array - */ - private function configResourceManifest(ContainerBuilder $builder, array $configFiles): array - { - $resources = []; - - foreach ($configFiles as $file) { - if ($file === '') { - continue; - } - - $resources[$file] = $this->fileFingerprint($file); - } - - foreach ($builder->getResources() as $resource) { - if ($resource instanceof FileResource) { - $file = $resource->getResource(); - $resources[$file] = $this->fileFingerprint($file); - - continue; - } - - if (!$resource instanceof FileExistenceResource) { - continue; - } - - $file = $resource->getResource(); - $resources[sprintf('exists:%s', $file)] = file_exists($file) ? 'exists:1' : 'exists:0'; - } - - ksort($resources); - - return $resources; - } - - private function configResourcesAreFresh(mixed $resources): bool - { - if (!is_array($resources) || $resources === []) { - return false; - } - - foreach ($resources as $path => $expected) { - if (!is_string($path) || !is_string($expected)) { - return false; - } - - if (str_starts_with($path, 'exists:')) { - $actual = file_exists(substr($path, 7)) ? 'exists:1' : 'exists:0'; - - if ($actual !== $expected) { - return false; - } - - continue; - } - - if ($this->fileFingerprint($path) !== $expected) { - return false; - } - } - - return true; - } - - private function shouldValidateCachedSourceResources(): bool - { - $value = $_SERVER['SYMPRESS_KERNEL_VALIDATE_SOURCE_RESOURCES'] - ?? $_ENV['SYMPRESS_KERNEL_VALIDATE_SOURCE_RESOURCES'] - ?? null; - - if ($value === null) { - return false; - } - - return filter_var($value, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE) === true; - } - - /** @param array $resources */ - private function sourceResourceFingerprint(array $resources): string - { - ksort($resources); - $parts = []; - - foreach ($resources as $path => $fingerprint) { - $parts[] = sprintf('%s:%s', $path, $fingerprint); - } - - return hash('sha256', implode('|', $parts)); - } - - private function sourceDirectoryMtime(string $directory): string - { - if (!is_dir($directory)) { - return 'missing'; - } - - return sprintf('dir:%s', (string) filemtime($directory)); - } - - private function sourceFileMtime(string $file): string - { - if (!is_file($file)) { - return 'missing'; - } - - return sprintf('file:%s:%s', (string) filemtime($file), (string) filesize($file)); - } - - private function prepareBuilder(ContainerBuilder $builder): void - { - $builderId = spl_object_id($builder); - - if (($this->preparedBuilders[$builderId] ?? false) === true) { - return; - } - - $this->preparedBuilders[$builderId] = true; - $this->registerCoreContainerServices($builder); - $this->registerTranslationLoader($builder); - $this->registerHookLoader($builder); - $this->registerRouteLoader($builder); - $this->registerConsoleApplication($builder); - $this->registerConsoleAttributes($builder); - $builder->registerAttributeForAutoconfiguration( - AsHook::class, - static function (ChildDefinition $definition, AsHook $attribute, \Reflector $reflector): void { - if (!$reflector instanceof \ReflectionClass && !$reflector instanceof \ReflectionMethod) { - return; - } - - $tag = $attribute->toTag(); - - if ($reflector instanceof \ReflectionMethod && $attribute->method === '__invoke') { - $tag['method'] = $reflector->getName(); - } - - $definition->addTag(HookLoader::TAG, $tag); - }, - ); - $this->registerRouteAttributes($builder); - $builder->addCompilerPass(new HookCompilerPass()); - $builder->addCompilerPass(new RouteCompilerPass()); - $this->build($builder); - } - - private function registerCoreContainerServices(ContainerBuilder $builder): void - { - if (!$builder->has('kernel')) { - $builder->setAlias('kernel', Container::KERNEL_ID)->setPublic(true); - } - - $builder->setAlias(SymfonyKernelInterface::class, Container::KERNEL_ID) - ->setPublic(true); - $builder->setAlias(DependencyInjectionKernelInterface::class, Container::KERNEL_ID) - ->setPublic(true); - - $this->registerFilesystemService($builder); - $this->registerEventDispatcherServices($builder); - $this->registerClockService($builder); - $this->registerExpressionLanguageService($builder); - - if (!$builder->hasDefinition('parameter_bag')) { - $builder->setDefinition( - 'parameter_bag', - (new Definition(ContainerBag::class)) - ->setArguments([new Reference('service_container')]), - ); - } - - $builder->setAlias(ContainerBagInterface::class, 'parameter_bag')->setPublic(false); - $builder->setAlias(ParameterBagInterface::class, 'parameter_bag')->setPublic(false); - - if (!$builder->hasDefinition('file_locator')) { - $builder->setDefinition( - 'file_locator', - (new Definition(FileLocator::class)) - ->setArguments([new Reference(Container::KERNEL_ID)]), - ); - } - - $builder->setAlias(FileLocator::class, 'file_locator')->setPublic(false); - - if (!$builder->hasDefinition('reverse_container')) { - $builder->setDefinition( - 'reverse_container', - (new Definition(ReverseContainer::class)) - ->setArguments([ - new Reference('service_container'), - new ServiceLocatorArgument([]), - ]), - ); - } - - $builder->setAlias(ReverseContainer::class, 'reverse_container')->setPublic(false); - - if (!$builder->hasDefinition('config_cache_factory')) { - $builder->setDefinition( - 'config_cache_factory', - (new Definition(ResourceCheckerConfigCacheFactory::class)) - ->setArguments([new TaggedIteratorArgument('config_cache.resource_checker')]), - ); - } - - if (!$builder->hasDefinition('dependency_injection.config.container_parameters_resource_checker')) { - $builder->setDefinition( - 'dependency_injection.config.container_parameters_resource_checker', - (new Definition(ContainerParametersResourceChecker::class)) - ->setArguments([new Reference('service_container')]) - ->addTag('config_cache.resource_checker', ['priority' => -980]), - ); - } - - if (!$builder->hasDefinition('config.resource.self_checking_resource_checker')) { - $builder->setDefinition( - 'config.resource.self_checking_resource_checker', - (new Definition(SelfCheckingResourceChecker::class)) - ->addTag('config_cache.resource_checker', ['priority' => -990]), - ); - } - - if (!$builder->hasDefinition('services_resetter')) { - $builder->setDefinition( - 'services_resetter', - (new Definition(ServicesResetter::class)) - ->setPublic(true) - ->setArguments([new IteratorArgument([]), []]), - ); - } - - $builder->setAlias(ServicesResetterInterface::class, 'services_resetter')->setPublic(true); - - if (!$builder->hasDefinition('container.env_var_processor')) { - $builder->setDefinition( - 'container.env_var_processor', - (new Definition(EnvVarProcessor::class)) - ->setArguments([ - new Reference('service_container'), - new TaggedIteratorArgument('container.env_var_loader'), - ]) - ->addTag('container.env_var_processor') - ->addTag('kernel.reset', ['method' => 'reset']), - ); - } - - $builder->registerForAutoconfiguration(EnvVarLoaderInterface::class) - ->addTag('container.env_var_loader'); - $builder->registerForAutoconfiguration(EnvVarProcessorInterface::class) - ->addTag('container.env_var_processor'); - $builder->registerForAutoconfiguration(ResourceCheckerInterface::class) - ->addTag('config_cache.resource_checker'); - $builder->registerForAutoconfiguration(ServiceLocator::class) - ->addTag('container.service_locator'); - $builder->registerForAutoconfiguration(ResetInterface::class) - ->addTag('kernel.reset', ['method' => 'reset']); - $builder->registerForAutoconfiguration(ServiceSubscriberInterface::class) - ->addTag('container.service_subscriber'); - $builder->registerForAutoconfiguration(CompilerPassInterface::class) - ->addTag('container.excluded', ['source' => 'because it is a compiler pass']); - $builder->registerForAutoconfiguration(\UnitEnum::class) - ->addTag('container.excluded', ['source' => 'because it is an enum']); - $builder->registerAttributeForAutoconfiguration( - \Attribute::class, - static function (ChildDefinition $definition): void { - $definition->addTag('container.excluded', ['source' => 'because it is a PHP attribute']); - }, - ); - $this->registerOptionalAutoconfiguration($builder); - - $builder->addCompilerPass( - new AddBehaviorDescribingTagsPass( - [ - 'container.do_not_inline', - 'container.excluded', - 'container.hot_path', - 'container.service_locator', - 'container.service_subscriber', - 'event_dispatcher.dispatcher', - 'kernel.event_listener', - 'kernel.event_subscriber', - 'kernel.reset', - ], - ), - PassConfig::TYPE_BEFORE_OPTIMIZATION, - 200, - ); - $builder->addCompilerPass(new ResettableServicePass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -32); - } - - private function registerFilesystemService(ContainerBuilder $builder): void - { - if (!$builder->hasDefinition('filesystem')) { - $builder->setDefinition('filesystem', new Definition(Filesystem::class)); - } - - $this->setAliasIfMissing($builder, Filesystem::class, 'filesystem'); - } - - private function registerEventDispatcherServices(ContainerBuilder $builder): void - { - $eventDispatcherClass = 'Symfony\Component\EventDispatcher\EventDispatcher'; - - if (class_exists($eventDispatcherClass) && !$builder->hasDefinition('event_dispatcher')) { - $builder->setDefinition( - 'event_dispatcher', - (new Definition($eventDispatcherClass)) - ->setPublic(true) - ->addTag('container.hot_path') - ->addTag('event_dispatcher.dispatcher', ['name' => 'event_dispatcher']), - ); - } - - $this->registerEventDispatcherAliases($builder); - - $registerListenersPass = 'Symfony\Component\EventDispatcher\DependencyInjection\RegisterListenersPass'; - - if (class_exists($registerListenersPass)) { - $builder->addCompilerPass(new $registerListenersPass(), PassConfig::TYPE_BEFORE_REMOVING); - } - - foreach ( - [ - 'Symfony\Component\EventDispatcher\EventDispatcherInterface' => 'event_dispatcher.dispatcher', - 'Symfony\Component\EventDispatcher\EventSubscriberInterface' => 'kernel.event_subscriber', - ] as $interface => $tag - ) { - if (!interface_exists($interface)) { - continue; - } - - $builder->registerForAutoconfiguration($interface)->addTag($tag); - } - - $asEventListenerClass = 'Symfony\Component\EventDispatcher\Attribute\AsEventListener'; - - if (!class_exists($asEventListenerClass)) { - return; - } - - $builder->registerAttributeForAutoconfiguration( - $asEventListenerClass, - static function (ChildDefinition $definition, object $attribute, \Reflector $reflector): void { - if (!$reflector instanceof \ReflectionClass && !$reflector instanceof \ReflectionMethod) { - return; - } - - $tagAttributes = array_filter( - get_object_vars($attribute), - static fn (mixed $value): bool => $value !== null, - ); - - if ($reflector instanceof \ReflectionMethod) { - if (isset($tagAttributes['method'])) { - throw new \LogicException( - sprintf( - 'AsEventListener attribute cannot declare a method on "%s::%s()".', - $reflector->class, - $reflector->name, - ), - ); - } - - $tagAttributes['method'] = $reflector->getName(); - } - - $definition->addTag('kernel.event_listener', $tagAttributes); - }, - ); - } - - private function registerEventDispatcherAliases(ContainerBuilder $builder): void - { - if (!$builder->hasDefinition('event_dispatcher') && !$builder->hasAlias('event_dispatcher')) { - return; - } - - foreach ( - [ - 'Symfony\Component\EventDispatcher\EventDispatcherInterface', - 'Symfony\Contracts\EventDispatcher\EventDispatcherInterface', - 'Psr\EventDispatcher\EventDispatcherInterface', - ] as $eventDispatcherInterface - ) { - if (!interface_exists($eventDispatcherInterface)) { - continue; - } - - $this->setAliasIfMissing($builder, $eventDispatcherInterface, 'event_dispatcher', true); - } - } - - private function registerClockService(ContainerBuilder $builder): void - { - $clockClass = 'Symfony\Component\Clock\Clock'; - - if (!class_exists($clockClass)) { - return; - } - - if (!$builder->hasDefinition('clock')) { - $builder->setDefinition('clock', new Definition($clockClass)); - } - - foreach (['Symfony\Component\Clock\ClockInterface', 'Psr\Clock\ClockInterface'] as $clockInterface) { - if (!interface_exists($clockInterface)) { - continue; - } - - $this->setAliasIfMissing($builder, $clockInterface, 'clock'); - } - } - - private function registerExpressionLanguageService(ContainerBuilder $builder): void - { - $expressionLanguageClass = 'Symfony\Component\DependencyInjection\ExpressionLanguage'; - - if (!class_exists($expressionLanguageClass) || $builder->hasDefinition('container.expression_language')) { - return; - } - - $builder->setDefinition('container.expression_language', new Definition($expressionLanguageClass)); - } - - private function registerOptionalAutoconfiguration(ContainerBuilder $builder): void - { - $this->registerLoggerAwareAutoconfiguration($builder); - $this->registerTestCaseExclusion($builder); - $this->registerLoaderInterfaceExclusion($builder); - } - - private function registerLoggerAwareAutoconfiguration(ContainerBuilder $builder): void - { - $loggerAwareInterface = 'Psr\Log\LoggerAwareInterface'; - - if (!interface_exists($loggerAwareInterface)) { - return; - } - - $builder->registerForAutoconfiguration($loggerAwareInterface) - ->addMethodCall( - 'setLogger', - [new Reference('logger', SymfonyDiContainerInterface::IGNORE_ON_INVALID_REFERENCE)], - ); - } - - private function registerTestCaseExclusion(ContainerBuilder $builder): void - { - $testCaseClass = 'PHPUnit\Framework\TestCase'; - - if (!class_exists($testCaseClass)) { - return; - } - - $builder->registerForAutoconfiguration($testCaseClass) - ->addTag('container.excluded', ['source' => 'because it is a test case']); - } - - private function registerLoaderInterfaceExclusion(ContainerBuilder $builder): void - { - if ($builder->hasDefinition(LoaderInterface::class)) { - return; - } - - $builder->setDefinition( - LoaderInterface::class, - (new Definition()) - ->setAbstract(true) - ->addTag('container.excluded', ['source' => 'because it is a loader interface']), - ); - } - - private function setAliasIfMissing( - ContainerBuilder $builder, - string $alias, - string $target, - bool $public = false, - ): void { - if ($builder->hasAlias($alias) || $builder->hasDefinition($alias)) { - return; - } - - $builder->setAlias($alias, $target)->setPublic($public); - } - - private function registerTranslationLoader(ContainerBuilder $builder): void - { - if ($builder->hasDefinition(TranslationLoader::class)) { - return; - } - - $builder->setDefinition( - TranslationLoader::class, - (new Definition(TranslationLoader::class)) - ->setPublic(true) - ->setArguments(['%kernel.translation_paths%']), - ); - } - - private function registerConsoleApplication(ContainerBuilder $builder): void - { - if (!$builder->hasDefinition(ConsoleApplicationFactory::class)) { - $builder->setDefinition( - ConsoleApplicationFactory::class, - (new Definition(ConsoleApplicationFactory::class)) - ->setPublic(true) - ->setArguments([ - new Reference(Container::KERNEL_ID), - new Reference('console.command_loader'), - ]), - ); - } - - if (!$builder->hasDefinition(Application::class)) { - $builder->setDefinition( - Application::class, - (new Definition(Application::class)) - ->setPublic(true) - ->setFactory([ - new Reference(ConsoleApplicationFactory::class), - 'create', - ]), - ); - } - - if ($builder->hasDefinition(WpCliConsoleBridge::class)) { - return; - } - - $builder->setDefinition( - WpCliConsoleBridge::class, - (new Definition(WpCliConsoleBridge::class)) - ->setArguments([new Reference(Application::class)]) - ->addTag( - HookLoader::TAG, - [ - 'hook' => 'muplugins_loaded', - 'method' => 'register', - 'priority' => 1, - ], - ), - ); - } - - private function registerConsoleAttributes(ContainerBuilder $builder): void - { - $builder->registerAttributeForAutoconfiguration( - AsCommand::class, - static function (ChildDefinition $definition): void { - $definition->addTag('console.command'); - }, - ); - } - - private function registerHookLoader(ContainerBuilder $builder): void - { - if ($builder->hasDefinition(HookLoader::class)) { - return; - } - - $builder->setDefinition( - HookLoader::class, - (new Definition(HookLoader::class)) - ->setPublic(true) - ->setArguments([null, []]), - ); - } - - private function registerRouteLoader(ContainerBuilder $builder): void - { - if ($builder->hasDefinition(RouteLoader::class)) { - return; - } - - $builder->setDefinition( - RouteLoader::class, - (new Definition(RouteLoader::class)) - ->setPublic(true) - ->setArguments([null, [], []]) - ->addTag( - HookLoader::TAG, - [ - 'hook' => 'template_redirect', - 'method' => 'dispatchFrontendRequest', - 'priority' => 0, - ], - ) - ->addTag( - HookLoader::TAG, - [ - 'hook' => 'rest_api_init', - 'method' => 'registerRestRoutes', - ], - ), - ); - } - - private function registerRouteAttributes(ContainerBuilder $builder): void - { - foreach ( - [ - Route::class, - 'Symfony\Component\Routing\Attribute\Route', - ] as $attributeClass - ) { - if (!class_exists($attributeClass)) { - continue; - } - - $builder->registerAttributeForAutoconfiguration( - $attributeClass, - static function (ChildDefinition $definition): void { - $definition->addTag(RouteLoader::TAG); - }, - ); - } - } - - private function ensureSynthetic(ContainerBuilder $builder, string $id, string $class): void - { - if ($builder->hasDefinition($id)) { - return; - } - - $builder->setDefinition( - $id, - (new Definition($class)) - ->setSynthetic(true) - ->setPublic(true), - ); - } - - private function newRuntimeContainer(string $class): PsrContainerInterface - { - if (!class_exists($class, false)) { - throw new \RuntimeException( - sprintf('Compiled container class "%s" was not loaded.', $class), - ); - } - - $container = new $class(); - - if (!$container instanceof PsrContainerInterface) { - throw new \RuntimeException( - sprintf( - 'Compiled container "%s" must implement %s.', - $class, - PsrContainerInterface::class, - ), - ); - } - - return $container; + return $this->coreServiceRegistrar ??= new CoreServiceRegistrar(); } } diff --git a/src/Kernel/ContainerCacheManager.php b/src/Kernel/ContainerCacheManager.php new file mode 100644 index 0000000..36523a3 --- /dev/null +++ b/src/Kernel/ContainerCacheManager.php @@ -0,0 +1,304 @@ + $runtimeConfigFiles */ + public function tryUseRuntimeContainer( + Container $container, + BundleRegistry $bundles, + array $runtimeConfigFiles, + ): bool { + + if ($this->fingerprints->tracksSourceChanges()) { + return false; + } + + $metaFile = sprintf('%s/meta.php', $this->cacheDir); + + if (!is_file($metaFile)) { + return false; + } + + $metadata = require $metaFile; + + if (!is_array($metadata)) { + return false; + } + + return $this->useCachedRuntimeContainer( + $container, + $this->fingerprints->stringKeyMap($metadata), + $this->fingerprints->fingerprint($bundles, $runtimeConfigFiles), + ); + } + + /** @param array $configFiles */ + public function createRuntimeContainer( + Container $container, + BundleRegistry $bundles, + array $configFiles, + ): void { + + $filesystem = new Filesystem(); + $filesystem->mkdir($this->cacheDir); + $metaFile = sprintf('%s/meta.php', $this->cacheDir); + $lockFile = sprintf('%s/container.lock', $this->cacheDir); + $fingerprint = $this->fingerprints->fingerprint($bundles, $configFiles); + $lock = fopen($lockFile, 'c+'); + + if (!is_resource($lock)) { + throw new \RuntimeException(sprintf('Unable to create cache lock "%s".', $lockFile)); + } + + try { + if (!flock($lock, LOCK_EX)) { + throw new \RuntimeException(sprintf('Unable to lock cache file "%s".', $lockFile)); + } + + clearstatcache(true, $metaFile); + + $metadata = is_file($metaFile) ? require $metaFile : null; + + if ( + is_array($metadata) + && $this->useCachedRuntimeContainer( + $container, + $this->fingerprints->stringKeyMap($metadata), + $fingerprint, + ) + ) { + return; + } + + $sourceResources = $this->fingerprints->sourceResourceManifest($bundles); + $sourceFingerprint = $this->fingerprints->sourceResourceFingerprint($sourceResources); + $cacheKey = substr(hash('sha256', "{$fingerprint}|{$sourceFingerprint}"), 0, 16); + $containerFile = sprintf('%s/container_%s.php', $this->cacheDir, $cacheKey); + clearstatcache(true, $containerFile); + + $class = sprintf('KernelContainer_%s', $cacheKey); + $runtime = $this->createRuntimeBuilder($container, $class); + $runtime->compile(true); + $configResources = $this->fingerprints->configResourceManifest($runtime, $configFiles); + $dump = (new PhpDumper($runtime))->dump( + [ + 'class' => $class, + 'debug' => $this->debug, + 'file' => $containerFile, + 'build_time' => $this->containerBuildTime($runtime), + 'inline_class_loader' => $this->debug, + ], + ); + + if (!is_string($dump)) { + throw new \RuntimeException('The runtime container dumper did not return PHP code.'); + } + + $filesystem->dumpFile($containerFile, $dump); + $filesystem->dumpFile( + $metaFile, + sprintf( + " $fingerprint, + 'config_resources' => $configResources, + 'source_fingerprint' => $sourceFingerprint, + 'source_resources' => $sourceResources, + 'class' => $class, + 'file' => basename($containerFile), + ], + true, + ), + ), + ); + + if (!class_exists($class, false)) { + require $containerFile; + } + + $container->useRuntimeContainer($this->newRuntimeContainer($class)); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + private function createRuntimeBuilder(Container $container, string $class): ContainerBuilder + { + $runtime = new ContainerBuilder(); + $this->copyExtensions($container->builder(), $runtime); + $runtime->merge($container->builder()); + $this->copyCompilerPasses($container->builder(), $runtime); + $runtime->setParameter('kernel.container_class', $class); + $runtime->getCompilerPassConfig()->setMergePass( + new MergeExtensionConfigurationPass($this->registeredExtensionAliases($runtime)), + ); + $runtime->addCompilerPass(new HookCompilerPass()); + $runtime->addCompilerPass(new RouteCompilerPass()); + $runtime->addCompilerPass(new AddConsoleCommandPass()); + $this->ensureSynthetic($runtime, Container::CONTAINER_ID, Container::class); + $this->ensureSynthetic($runtime, Container::CONFIG_ID, SiteConfig::class); + $this->ensureSynthetic($runtime, Container::CONTEXT_ID, WpContext::class); + $this->ensureSynthetic($runtime, Container::KERNEL_ID, KernelInterface::class); + $this->ensureSynthetic($runtime, Container::APP_ID, App::class); + + $runtime->setAlias(Container::class, Container::CONTAINER_ID)->setPublic(true); + $runtime->setAlias(PsrContainerInterface::class, Container::CONTAINER_ID)->setPublic(true); + $runtime->setAlias(SiteConfig::class, Container::CONFIG_ID)->setPublic(true); + $runtime->setAlias(WpContext::class, Container::CONTEXT_ID)->setPublic(true); + $runtime->setAlias(KernelInterface::class, Container::KERNEL_ID)->setPublic(true); + $runtime->setAlias(App::class, Container::APP_ID)->setPublic(true); + + return $runtime; + } + + /** @return list */ + private function registeredExtensionAliases(ContainerBuilder $builder): array + { + $aliases = []; + + foreach ($builder->getExtensions() as $extension) { + $aliases[] = $extension->getAlias(); + } + + return array_values(array_unique($aliases)); + } + + private function copyExtensions(ContainerBuilder $source, ContainerBuilder $target): void + { + foreach ($source->getExtensions() as $extension) { + $target->registerExtension($extension); + } + } + + private function copyCompilerPasses(ContainerBuilder $source, ContainerBuilder $target): void + { + $sourcePasses = $source->getCompilerPassConfig(); + $targetPasses = $target->getCompilerPassConfig(); + + $targetPasses->setBeforeOptimizationPasses($sourcePasses->getBeforeOptimizationPasses()); + $targetPasses->setOptimizationPasses($sourcePasses->getOptimizationPasses()); + $targetPasses->setBeforeRemovingPasses($sourcePasses->getBeforeRemovingPasses()); + $targetPasses->setRemovingPasses($sourcePasses->getRemovingPasses()); + $targetPasses->setAfterRemovingPasses($sourcePasses->getAfterRemovingPasses()); + } + + /** @param array $metadata */ + private function useCachedRuntimeContainer( + Container $container, + array $metadata, + string $fingerprint, + ): bool { + + if ( + ($metadata['fingerprint'] ?? null) !== $fingerprint + || !is_string($metadata['class'] ?? null) + || !is_string($metadata['file'] ?? null) + ) { + return false; + } + + if (!$this->fingerprints->configResourcesAreFresh($metadata['config_resources'] ?? null)) { + return false; + } + + if ( + $this->fingerprints->shouldValidateCachedSourceResources() + && !$this->fingerprints->sourceResourcesAreFresh($metadata['source_resources'] ?? null) + ) { + return false; + } + + $cachedContainerFile = sprintf('%s/%s', $this->cacheDir, basename($metadata['file'])); + + if (!is_file($cachedContainerFile)) { + return false; + } + + require_once $cachedContainerFile; + $class = $metadata['class']; + + if (!class_exists($class, false)) { + return false; + } + + $container->useRuntimeContainer($this->newRuntimeContainer($class)); + + return true; + } + + private function containerBuildTime(ContainerBuilder $builder): int + { + if ($builder->hasParameter('kernel.container_build_time')) { + $buildTime = $builder->getParameter('kernel.container_build_time'); + + if (is_int($buildTime)) { + return $buildTime; + } + + if (is_string($buildTime) && ctype_digit($buildTime)) { + return (int) $buildTime; + } + } + + $sourceDateEpoch = $_SERVER['SOURCE_DATE_EPOCH'] ?? $_ENV['SOURCE_DATE_EPOCH'] ?? null; + + if (is_scalar($sourceDateEpoch) && filter_var($sourceDateEpoch, \FILTER_VALIDATE_INT) !== false) { + return (int) $sourceDateEpoch; + } + + return time(); + } + + private function ensureSynthetic(ContainerBuilder $builder, string $id, string $class): void + { + if ($builder->hasDefinition($id)) { + return; + } + + $builder->setDefinition( + $id, + (new Definition($class)) + ->setSynthetic(true) + ->setPublic(true), + ); + } + + private function newRuntimeContainer(string $class): PsrContainerInterface + { + $instance = new $class(); + + if (!$instance instanceof PsrContainerInterface) { + throw new \RuntimeException(sprintf('Runtime container "%s" is invalid.', $class)); + } + + return $instance; + } +} diff --git a/src/Kernel/ContainerResourceFingerprinter.php b/src/Kernel/ContainerResourceFingerprinter.php new file mode 100644 index 0000000..48d1194 --- /dev/null +++ b/src/Kernel/ContainerResourceFingerprinter.php @@ -0,0 +1,343 @@ + $configFiles */ + public function fingerprint(BundleRegistry $bundles, array $configFiles): string + { + sort($configFiles); + + $parts = [ + $this->projectDir, + $this->environment, + (string) (int) $this->debug, + $this->deploymentFingerprint(), + $this->kernelFingerprint(), + ...$bundles->identityFingerprintParts(), + ]; + + foreach ($configFiles as $file) { + $parts[] = sprintf( + '%s:%s', + $file, + is_file($file) ? $this->fileFingerprint($file) : 'missing', + ); + } + + return hash('sha256', implode('|', $parts)); + } + + public function tracksSourceChanges(): bool + { + return $this->debug && !$this->resourceTrackingDisabled(); + } + + /** + * @param array $values + * @return array + */ + public function stringKeyMap(array $values): array + { + $map = []; + + foreach ($values as $key => $value) { + if (!is_string($key)) { + continue; + } + + $map[$key] = $value; + } + + return $map; + } + + /** @return array */ + public function sourceResourceManifest(BundleRegistry $bundles): array + { + $resources = []; + $this->collectSourceResources(dirname(__DIR__, 2) . '/src', $resources); + + foreach ($bundles->all() as $metadata) { + $this->collectSourceResources(sprintf('%s/src', rtrim($metadata->path(), '/')), $resources, 'php'); + + $bundleFile = (new \ReflectionObject($metadata->bundle()))->getFileName(); + + if (!is_string($bundleFile)) { + continue; + } + + $resources[$bundleFile] = $this->sourceFileMtime($bundleFile); + } + + ksort($resources); + + return $resources; + } + + /** @param array $resources */ + public function sourceResourceFingerprint(array $resources): string + { + ksort($resources); + $parts = []; + + foreach ($resources as $path => $fingerprint) { + $parts[] = sprintf('%s:%s', $path, $fingerprint); + } + + return hash('sha256', implode('|', $parts)); + } + + public function sourceResourcesAreFresh(mixed $resources): bool + { + if (!is_array($resources) || $resources === []) { + return false; + } + + foreach ($resources as $path => $expected) { + if (!is_string($path) || !is_string($expected)) { + return false; + } + + $actual = str_starts_with($expected, 'dir:') + ? $this->sourceDirectoryMtime($path) + : $this->sourceFileMtime($path); + + if ($actual !== $expected) { + return false; + } + } + + return true; + } + + /** + * @param array $configFiles + * @return array + */ + public function configResourceManifest(ContainerBuilder $builder, array $configFiles): array + { + $resources = []; + + foreach ($configFiles as $file) { + if ($file === '') { + continue; + } + + $resources[$file] = $this->fileFingerprint($file); + } + + foreach ($builder->getResources() as $resource) { + if ($resource instanceof FileResource) { + $file = $resource->getResource(); + $resources[$file] = $this->fileFingerprint($file); + + continue; + } + + if (!$resource instanceof FileExistenceResource) { + continue; + } + + $file = $resource->getResource(); + $resources[sprintf('exists:%s', $file)] = file_exists($file) ? 'exists:1' : 'exists:0'; + } + + ksort($resources); + + return $resources; + } + + public function configResourcesAreFresh(mixed $resources): bool + { + if (!is_array($resources) || $resources === []) { + return false; + } + + foreach ($resources as $path => $expected) { + if (!is_string($path) || !is_string($expected)) { + return false; + } + + if (str_starts_with($path, 'exists:')) { + $actual = file_exists(substr($path, 7)) ? 'exists:1' : 'exists:0'; + + if ($actual !== $expected) { + return false; + } + + continue; + } + + if ($this->fileFingerprint($path) !== $expected) { + return false; + } + } + + return true; + } + + public function shouldValidateCachedSourceResources(): bool + { + $value = $_SERVER['SYMPRESS_KERNEL_VALIDATE_SOURCE_RESOURCES'] + ?? $_ENV['SYMPRESS_KERNEL_VALIDATE_SOURCE_RESOURCES'] + ?? null; + + if ($value === null) { + return false; + } + + return filter_var($value, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE) === true; + } + + private function resourceTrackingDisabled(): bool + { + $value = $_SERVER['SYMFONY_DISABLE_RESOURCE_TRACKING'] + ?? $_ENV['SYMFONY_DISABLE_RESOURCE_TRACKING'] + ?? null; + + if ($value === null) { + return false; + } + + if (is_array($value)) { + return $value !== []; + } + + if (!is_scalar($value) && !$value instanceof \Stringable) { + return true; + } + + $value = (string) $value; + + return filter_var($value, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE) ?? ($value !== ''); + } + + private function fileFingerprint(string $file): string + { + if (!is_file($file)) { + return 'missing'; + } + + if ($this->tracksSourceChanges()) { + $hash = sha1_file($file); + + return is_string($hash) ? $hash : 'unreadable'; + } + + return $this->sourceFileMtime($file); + } + + private function deploymentFingerprint(): string + { + $buildId = defined('SYMPRESS_KERNEL_BUILD_ID') + ? constant('SYMPRESS_KERNEL_BUILD_ID') + : getenv('SYMPRESS_KERNEL_BUILD_ID'); + + if ((is_scalar($buildId) || $buildId instanceof \Stringable) && (string) $buildId !== '') { + return 'build:' . (string) $buildId; + } + + $composerLock = sprintf('%s/composer.lock', $this->projectDir); + + if (is_file($composerLock)) { + return 'composer-lock:' . $this->fileFingerprint($composerLock); + } + + return 'build:implicit'; + } + + private function kernelFingerprint(): string + { + $packageDir = dirname(__DIR__, 2); + $composerFile = sprintf('%s/composer.json', $packageDir); + + return hash( + 'sha256', + implode( + '|', + [ + $packageDir, + sprintf('%s:%s', $composerFile, $this->fileFingerprint($composerFile)), + ], + ), + ); + } + + /** + * @param array $resources + */ + private function collectSourceResources( + string $directory, + array &$resources, + ?string $fileExtension = null, + ): void { + + if (!is_dir($directory)) { + return; + } + + $resources[$directory] = $this->sourceDirectoryMtime($directory); + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($iterator as $resource) { + if (!$resource instanceof \SplFileInfo) { + continue; + } + + $pathname = $resource->getPathname(); + + if ($resource->isDir()) { + $resources[$pathname] = $this->sourceDirectoryMtime($pathname); + + continue; + } + + if (!$resource->isFile()) { + continue; + } + + if ($fileExtension !== null && $resource->getExtension() !== $fileExtension) { + continue; + } + + $resources[$pathname] = $this->sourceFileMtime($pathname); + } + } + + private function sourceDirectoryMtime(string $directory): string + { + if (!is_dir($directory)) { + return 'missing'; + } + + return sprintf('dir:%s', (string) filemtime($directory)); + } + + private function sourceFileMtime(string $file): string + { + if (!is_file($file)) { + return 'missing'; + } + + return sprintf('file:%s:%s', (string) filemtime($file), (string) filesize($file)); + } +} diff --git a/src/Kernel/CoreServiceRegistrar.php b/src/Kernel/CoreServiceRegistrar.php new file mode 100644 index 0000000..e02dfb2 --- /dev/null +++ b/src/Kernel/CoreServiceRegistrar.php @@ -0,0 +1,565 @@ + */ + private array $preparedBuilders = []; + + public function prepare(ContainerBuilder $builder, \Closure $build): void + { + $builderId = spl_object_id($builder); + + if (($this->preparedBuilders[$builderId] ?? false) === true) { + return; + } + + $this->preparedBuilders[$builderId] = true; + $this->registerCoreContainerServices($builder); + $this->registerTranslationLoader($builder); + $this->registerHookLoader($builder); + $this->registerRouteLoader($builder); + $this->registerConsoleApplication($builder); + $this->registerConsoleAttributes($builder); + $builder->registerAttributeForAutoconfiguration( + AsHook::class, + static function (ChildDefinition $definition, AsHook $attribute, \Reflector $reflector): void { + if (!$reflector instanceof \ReflectionClass && !$reflector instanceof \ReflectionMethod) { + return; + } + + $tag = $attribute->toTag(); + + if ($reflector instanceof \ReflectionMethod && $attribute->method === '__invoke') { + $tag['method'] = $reflector->getName(); + } + + $definition->addTag(HookLoader::TAG, $tag); + }, + ); + $this->registerRouteAttributes($builder); + $builder->addCompilerPass(new HookCompilerPass()); + $builder->addCompilerPass(new RouteCompilerPass()); + $build($builder); + } + + private function registerCoreContainerServices(ContainerBuilder $builder): void + { + if (!$builder->has('kernel')) { + $builder->setAlias('kernel', Container::KERNEL_ID)->setPublic(true); + } + + $builder->setAlias(HttpKernelInterface::class, Container::KERNEL_ID) + ->setPublic(true); + $builder->setAlias(DependencyInjectionKernelInterface::class, Container::KERNEL_ID) + ->setPublic(true); + + $this->registerFilesystemService($builder); + $this->registerEventDispatcherServices($builder); + $this->registerClockService($builder); + $this->registerExpressionLanguageService($builder); + + if (!$builder->hasDefinition('parameter_bag')) { + $builder->setDefinition( + 'parameter_bag', + (new Definition(ContainerBag::class)) + ->setArguments([new Reference('service_container')]), + ); + } + + $builder->setAlias(ContainerBagInterface::class, 'parameter_bag')->setPublic(false); + $builder->setAlias(ParameterBagInterface::class, 'parameter_bag')->setPublic(false); + + if (!$builder->hasDefinition('file_locator')) { + $builder->setDefinition( + 'file_locator', + (new Definition(FileLocator::class)) + ->setArguments([new Reference(Container::KERNEL_ID)]), + ); + } + + $builder->setAlias(FileLocator::class, 'file_locator')->setPublic(false); + + if (!$builder->hasDefinition('reverse_container')) { + $builder->setDefinition( + 'reverse_container', + (new Definition(ReverseContainer::class)) + ->setArguments([ + new Reference('service_container'), + new ServiceLocatorArgument([]), + ]), + ); + } + + $builder->setAlias(ReverseContainer::class, 'reverse_container')->setPublic(false); + + if (!$builder->hasDefinition('config_cache_factory')) { + $builder->setDefinition( + 'config_cache_factory', + (new Definition(ResourceCheckerConfigCacheFactory::class)) + ->setArguments([new TaggedIteratorArgument('config_cache.resource_checker')]), + ); + } + + if (!$builder->hasDefinition('dependency_injection.config.container_parameters_resource_checker')) { + $builder->setDefinition( + 'dependency_injection.config.container_parameters_resource_checker', + (new Definition(ContainerParametersResourceChecker::class)) + ->setArguments([new Reference('service_container')]) + ->addTag('config_cache.resource_checker', ['priority' => -980]), + ); + } + + if (!$builder->hasDefinition('config.resource.self_checking_resource_checker')) { + $builder->setDefinition( + 'config.resource.self_checking_resource_checker', + (new Definition(SelfCheckingResourceChecker::class)) + ->addTag('config_cache.resource_checker', ['priority' => -990]), + ); + } + + if (!$builder->hasDefinition('services_resetter')) { + $builder->setDefinition( + 'services_resetter', + (new Definition(ServicesResetter::class)) + ->setPublic(true) + ->setArguments([new IteratorArgument([]), []]), + ); + } + + $builder->setAlias(ServicesResetterInterface::class, 'services_resetter')->setPublic(true); + + if (!$builder->hasDefinition('container.env_var_processor')) { + $builder->setDefinition( + 'container.env_var_processor', + (new Definition(EnvVarProcessor::class)) + ->setArguments([ + new Reference('service_container'), + new TaggedIteratorArgument('container.env_var_loader'), + ]) + ->addTag('container.env_var_processor') + ->addTag('kernel.reset', ['method' => 'reset']), + ); + } + + $builder->registerForAutoconfiguration(EnvVarLoaderInterface::class) + ->addTag('container.env_var_loader'); + $builder->registerForAutoconfiguration(EnvVarProcessorInterface::class) + ->addTag('container.env_var_processor'); + $builder->registerForAutoconfiguration(ResourceCheckerInterface::class) + ->addTag('config_cache.resource_checker'); + $builder->registerForAutoconfiguration(ServiceLocator::class) + ->addTag('container.service_locator'); + $builder->registerForAutoconfiguration(ResetInterface::class) + ->addTag('kernel.reset', ['method' => 'reset']); + $builder->registerForAutoconfiguration(ServiceSubscriberInterface::class) + ->addTag('container.service_subscriber'); + $builder->registerForAutoconfiguration(CompilerPassInterface::class) + ->addTag('container.excluded', ['source' => 'because it is a compiler pass']); + $builder->registerForAutoconfiguration(\UnitEnum::class) + ->addTag('container.excluded', ['source' => 'because it is an enum']); + $builder->registerAttributeForAutoconfiguration( + \Attribute::class, + static function (ChildDefinition $definition): void { + $definition->addTag('container.excluded', ['source' => 'because it is a PHP attribute']); + }, + ); + $this->registerOptionalAutoconfiguration($builder); + + $builder->addCompilerPass( + new AddBehaviorDescribingTagsPass( + [ + 'container.do_not_inline', + 'container.excluded', + 'container.hot_path', + 'container.service_locator', + 'container.service_subscriber', + 'event_dispatcher.dispatcher', + 'kernel.event_listener', + 'kernel.event_subscriber', + 'kernel.reset', + ], + ), + PassConfig::TYPE_BEFORE_OPTIMIZATION, + 200, + ); + $builder->addCompilerPass(new ResettableServicePass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -32); + } + + private function registerFilesystemService(ContainerBuilder $builder): void + { + if (!$builder->hasDefinition('filesystem')) { + $builder->setDefinition('filesystem', new Definition(Filesystem::class)); + } + + $this->setAliasIfMissing($builder, Filesystem::class, 'filesystem'); + } + + private function registerEventDispatcherServices(ContainerBuilder $builder): void + { + $eventDispatcherClass = 'Symfony\Component\EventDispatcher\EventDispatcher'; + + if (class_exists($eventDispatcherClass) && !$builder->hasDefinition('event_dispatcher')) { + $builder->setDefinition( + 'event_dispatcher', + (new Definition($eventDispatcherClass)) + ->setPublic(true) + ->addTag('container.hot_path') + ->addTag('event_dispatcher.dispatcher', ['name' => 'event_dispatcher']), + ); + } + + $this->registerEventDispatcherAliases($builder); + + $registerListenersPass = 'Symfony\Component\EventDispatcher\DependencyInjection\RegisterListenersPass'; + + if (class_exists($registerListenersPass)) { + $builder->addCompilerPass(new $registerListenersPass(), PassConfig::TYPE_BEFORE_REMOVING); + } + + foreach ( + [ + 'Symfony\Component\EventDispatcher\EventDispatcherInterface' => 'event_dispatcher.dispatcher', + 'Symfony\Component\EventDispatcher\EventSubscriberInterface' => 'kernel.event_subscriber', + ] as $interface => $tag + ) { + if (!interface_exists($interface)) { + continue; + } + + $builder->registerForAutoconfiguration($interface)->addTag($tag); + } + + $asEventListenerClass = 'Symfony\Component\EventDispatcher\Attribute\AsEventListener'; + + if (!class_exists($asEventListenerClass)) { + return; + } + + $builder->registerAttributeForAutoconfiguration( + $asEventListenerClass, + static function (ChildDefinition $definition, object $attribute, \Reflector $reflector): void { + if (!$reflector instanceof \ReflectionClass && !$reflector instanceof \ReflectionMethod) { + return; + } + + $tagAttributes = array_filter( + get_object_vars($attribute), + static fn (mixed $value): bool => $value !== null, + ); + + if ($reflector instanceof \ReflectionMethod) { + if (isset($tagAttributes['method'])) { + throw new \LogicException( + sprintf( + 'AsEventListener attribute cannot declare a method on "%s::%s()".', + $reflector->class, + $reflector->name, + ), + ); + } + + $tagAttributes['method'] = $reflector->getName(); + } + + $definition->addTag('kernel.event_listener', $tagAttributes); + }, + ); + } + + private function registerEventDispatcherAliases(ContainerBuilder $builder): void + { + if (!$builder->hasDefinition('event_dispatcher') && !$builder->hasAlias('event_dispatcher')) { + return; + } + + foreach ( + [ + 'Symfony\Component\EventDispatcher\EventDispatcherInterface', + 'Symfony\Contracts\EventDispatcher\EventDispatcherInterface', + 'Psr\EventDispatcher\EventDispatcherInterface', + ] as $eventDispatcherInterface + ) { + if (!interface_exists($eventDispatcherInterface)) { + continue; + } + + $this->setAliasIfMissing($builder, $eventDispatcherInterface, 'event_dispatcher', true); + } + } + + private function registerClockService(ContainerBuilder $builder): void + { + $clockClass = 'Symfony\Component\Clock\Clock'; + + if (!class_exists($clockClass)) { + return; + } + + if (!$builder->hasDefinition('clock')) { + $builder->setDefinition('clock', new Definition($clockClass)); + } + + foreach (['Symfony\Component\Clock\ClockInterface', 'Psr\Clock\ClockInterface'] as $clockInterface) { + if (!interface_exists($clockInterface)) { + continue; + } + + $this->setAliasIfMissing($builder, $clockInterface, 'clock'); + } + } + + private function registerExpressionLanguageService(ContainerBuilder $builder): void + { + $expressionLanguageClass = 'Symfony\Component\DependencyInjection\ExpressionLanguage'; + + if (!class_exists($expressionLanguageClass) || $builder->hasDefinition('container.expression_language')) { + return; + } + + $builder->setDefinition('container.expression_language', new Definition($expressionLanguageClass)); + } + + private function registerOptionalAutoconfiguration(ContainerBuilder $builder): void + { + $this->registerLoggerAwareAutoconfiguration($builder); + $this->registerTestCaseExclusion($builder); + $this->registerLoaderInterfaceExclusion($builder); + } + + private function registerLoggerAwareAutoconfiguration(ContainerBuilder $builder): void + { + $loggerAwareInterface = 'Psr\Log\LoggerAwareInterface'; + + if (!interface_exists($loggerAwareInterface)) { + return; + } + + $builder->registerForAutoconfiguration($loggerAwareInterface) + ->addMethodCall( + 'setLogger', + [new Reference('logger', SymfonyDiContainerInterface::IGNORE_ON_INVALID_REFERENCE)], + ); + } + + private function registerTestCaseExclusion(ContainerBuilder $builder): void + { + $testCaseClass = 'PHPUnit\Framework\TestCase'; + + if (!class_exists($testCaseClass)) { + return; + } + + $builder->registerForAutoconfiguration($testCaseClass) + ->addTag('container.excluded', ['source' => 'because it is a test case']); + } + + private function registerLoaderInterfaceExclusion(ContainerBuilder $builder): void + { + if ($builder->hasDefinition(LoaderInterface::class)) { + return; + } + + $builder->setDefinition( + LoaderInterface::class, + (new Definition()) + ->setAbstract(true) + ->addTag('container.excluded', ['source' => 'because it is a loader interface']), + ); + } + + private function setAliasIfMissing( + ContainerBuilder $builder, + string $alias, + string $target, + bool $public = false, + ): void { + if ($builder->hasAlias($alias) || $builder->hasDefinition($alias)) { + return; + } + + $builder->setAlias($alias, $target)->setPublic($public); + } + + private function registerTranslationLoader(ContainerBuilder $builder): void + { + if ($builder->hasDefinition(TranslationLoader::class)) { + return; + } + + $builder->setDefinition( + TranslationLoader::class, + (new Definition(TranslationLoader::class)) + ->setPublic(true) + ->setArguments(['%kernel.translation_paths%']), + ); + } + + private function registerConsoleApplication(ContainerBuilder $builder): void + { + if (!$builder->hasDefinition(ConsoleApplicationFactory::class)) { + $builder->setDefinition( + ConsoleApplicationFactory::class, + (new Definition(ConsoleApplicationFactory::class)) + ->setPublic(true) + ->setArguments([ + new Reference(Container::KERNEL_ID), + new Reference('console.command_loader'), + ]), + ); + } + + if (!$builder->hasDefinition(Application::class)) { + $builder->setDefinition( + Application::class, + (new Definition(Application::class)) + ->setPublic(true) + ->setFactory([ + new Reference(ConsoleApplicationFactory::class), + 'create', + ]), + ); + } + + if ($builder->hasDefinition(WpCliConsoleBridge::class)) { + return; + } + + $builder->setDefinition( + WpCliConsoleBridge::class, + (new Definition(WpCliConsoleBridge::class)) + ->setArguments([new Reference(Application::class)]) + ->addTag( + HookLoader::TAG, + [ + 'hook' => 'muplugins_loaded', + 'method' => 'register', + 'priority' => 1, + ], + ), + ); + } + + private function registerConsoleAttributes(ContainerBuilder $builder): void + { + $builder->registerAttributeForAutoconfiguration( + AsCommand::class, + static function (ChildDefinition $definition): void { + $definition->addTag('console.command'); + }, + ); + } + + private function registerHookLoader(ContainerBuilder $builder): void + { + if ($builder->hasDefinition(HookLoader::class)) { + return; + } + + $builder->setDefinition( + HookLoader::class, + (new Definition(HookLoader::class)) + ->setPublic(true) + ->setArguments([null, []]), + ); + } + + private function registerRouteLoader(ContainerBuilder $builder): void + { + if ($builder->hasDefinition(RouteLoader::class)) { + return; + } + + $builder->setDefinition( + RouteLoader::class, + (new Definition(RouteLoader::class)) + ->setPublic(true) + ->setArguments([null, [], []]) + ->addTag( + HookLoader::TAG, + [ + 'hook' => 'template_redirect', + 'method' => 'dispatchFrontendRequest', + 'priority' => 0, + ], + ) + ->addTag( + HookLoader::TAG, + [ + 'hook' => 'rest_api_init', + 'method' => 'registerRestRoutes', + ], + ), + ); + } + + private function registerRouteAttributes(ContainerBuilder $builder): void + { + foreach ( + [ + Route::class, + 'Symfony\Component\Routing\Attribute\Route', + ] as $attributeClass + ) { + if (!class_exists($attributeClass)) { + continue; + } + + $builder->registerAttributeForAutoconfiguration( + $attributeClass, + static function (ChildDefinition $definition): void { + $definition->addTag(RouteLoader::TAG); + }, + ); + } + } +} diff --git a/src/Kernel/KernelConfigurationResolver.php b/src/Kernel/KernelConfigurationResolver.php new file mode 100644 index 0000000..9e60aef --- /dev/null +++ b/src/Kernel/KernelConfigurationResolver.php @@ -0,0 +1,334 @@ +serverString('APP_CACHE_DIR'); + + if ($dir !== null) { + return sprintf('%s/kernel', $this->environmentDirectory($dir)); + } + + return sprintf('%s/var/cache/%s/kernel', $this->projectDir, $this->environment); + } + + public function buildDir(): string + { + $dir = $this->serverString('APP_BUILD_DIR'); + + if ($dir !== null) { + return sprintf('%s/kernel', $this->environmentDirectory($dir)); + } + + return $this->cacheDir(); + } + + public function shareDir(): ?string + { + $dir = $this->serverNullableDirectory('APP_SHARE_DIR'); + + if ($dir !== null) { + return sprintf('%s/kernel', $this->environmentDirectory($dir)); + } + + if ($this->serverValueIsFalse('APP_SHARE_DIR')) { + return null; + } + + return $this->cacheDir(); + } + + public function logDir(): ?string + { + $dir = $this->serverNullableDirectory('APP_LOG_DIR'); + + if ($dir !== null) { + return $this->environmentDirectory($dir); + } + + if ($this->serverValueIsFalse('APP_LOG_DIR')) { + return null; + } + + return sprintf('%s/var/log', $this->projectDir); + } + + /** @return list */ + public function packagePrefixes(): array + { + $configured = $this->config->get('KERNEL_PACKAGE_PREFIXES', null); + + if ($configured === null) { + $configured = $this->composerKernelPackagePrefixes(); + } + + return $this->normalizePackagePrefixes($configured); + } + + /** @return list */ + public function knownEnvironments(): array + { + $known = [ + $this->environment, + 'all', + 'dev', + 'development', + 'local', + 'prod', + 'production', + 'stage', + 'staging', + 'test', + ]; + $bundlesDefinition = sprintf('%s/config/bundles.php', $this->projectDir); + + if (!is_file($bundlesDefinition)) { + return $this->normalizeKnownEnvironments($known); + } + + $configuration = require $bundlesDefinition; + + if (!is_array($configuration)) { + return $this->normalizeKnownEnvironments($known); + } + + foreach ($configuration as $envs) { + if (!is_array($envs)) { + continue; + } + + foreach (array_keys($envs) as $environment) { + if (!is_string($environment)) { + continue; + } + + $known[] = $environment; + } + } + + return $this->normalizeKnownEnvironments($known); + } + + /** @return array */ + public function configDirectories(BundleRegistry $bundles): array + { + $directories = []; + $libraryDir = dirname(__DIR__, 2) . '/config'; + $siteDir = sprintf('%s/config', $this->projectDir); + + if (is_dir($libraryDir)) { + $directories[] = $libraryDir; + } + + foreach ($bundles->configDirectories() as $configDir) { + $directories[] = $configDir; + } + + if (is_dir($siteDir)) { + $directories[] = $siteDir; + } + + return array_values(array_unique($directories)); + } + + /** @return array */ + public function resolveConfigFiles(string $configDir): array + { + $files = []; + + foreach ($this->patterns($configDir) as $pattern) { + $matches = glob($pattern, GLOB_BRACE) ?: []; + sort($matches); + $files = [...$files, ...$matches]; + } + + return $files; + } + + /** @return array */ + public function runtimeConfigFiles(BundleRegistry $bundles): array + { + $files = []; + + foreach ($this->configDirectories($bundles) as $configDir) { + $files = [...$files, ...$this->resolveConfigFiles($configDir)]; + } + + return array_values(array_unique($files)); + } + + /** + * @param array $values + * @return array + */ + public function stringKeyMap(array $values): array + { + $map = []; + + foreach ($values as $key => $value) { + if (!is_string($key)) { + continue; + } + + $map[$key] = $value; + } + + return $map; + } + + private function composerKernelPackagePrefixes(): mixed + { + $composerFile = sprintf('%s/composer.json', $this->projectDir); + + if (!is_file($composerFile)) { + return null; + } + + $contents = file_get_contents($composerFile); + + if (!is_string($contents) || $contents === '') { + return null; + } + + $metadata = json_decode($contents, true); + + if (!is_array($metadata)) { + return null; + } + + $metadata = $this->stringKeyMap($metadata); + $extra = $metadata['extra'] ?? null; + $kernel = is_array($extra) ? ($extra['kernel'] ?? null) : null; + + if (!is_array($kernel)) { + return null; + } + + return $kernel['package_prefixes'] ?? $kernel['packagePrefixes'] ?? null; + } + + /** @return list */ + private function normalizePackagePrefixes(mixed $packagePrefixes): array + { + if (is_string($packagePrefixes)) { + $packagePrefixes = preg_split('/[,\s]+/', $packagePrefixes) ?: []; + } + + if (!is_array($packagePrefixes)) { + return []; + } + + $normalized = []; + + foreach ($packagePrefixes as $prefix) { + if (!is_scalar($prefix) && !$prefix instanceof \Stringable) { + continue; + } + + $prefix = trim((string) $prefix); + + if ($prefix === '') { + continue; + } + + $normalized[] = str_ends_with($prefix, '/') ? $prefix : "{$prefix}/"; + } + + return array_values(array_unique($normalized)); + } + + /** + * @param list $environments + * @return list + */ + private function normalizeKnownEnvironments(array $environments): array + { + return array_values( + array_unique( + array_filter($environments, static fn (string $env): bool => $env !== ''), + ), + ); + } + + private function serverString(string $name): ?string + { + $value = $_SERVER[$name] ?? $_ENV[$name] ?? null; + + if (!is_scalar($value) && !$value instanceof \Stringable) { + return null; + } + + $value = trim((string) $value); + + return $value === '' ? null : $value; + } + + private function serverNullableDirectory(string $name): ?string + { + if ($this->serverValueIsFalse($name)) { + return null; + } + + return $this->serverString($name); + } + + private function serverValueIsFalse(string $name): bool + { + $value = $_SERVER[$name] ?? $_ENV[$name] ?? null; + + if ($value === null) { + return false; + } + + return filter_var($value, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE) === false; + } + + private function environmentDirectory(string $directory): string + { + if ($directory !== '' && in_array($directory[0], ['/', '\\'], true)) { + return sprintf('%s/%s', rtrim($directory, '/'), $this->environment); + } + + if ( + DIRECTORY_SEPARATOR === '\\' + && isset($directory[1]) + && $directory[1] === ':' + && preg_match('/^[A-Za-z]:/', $directory) === 1 + ) { + return sprintf('%s/%s', rtrim($directory, '/'), $this->environment); + } + + return sprintf('%s/%s/%s', $this->projectDir, trim($directory, '/'), $this->environment); + } + + /** @return array */ + private function patterns(string $configDir): array + { + $env = $this->environment; + $extensions = '{php,yaml,yml,ini}'; + + return [ + sprintf('%s/packages/*.%s', $configDir, $extensions), + sprintf('%s/packages/%s/*.%s', $configDir, $env, $extensions), + sprintf('%s/services.%s', $configDir, $extensions), + sprintf('%s/services_%s.%s', $configDir, $env, $extensions), + sprintf('%s/wordpress.%s', $configDir, $extensions), + sprintf('%s/wordpress_%s.%s', $configDir, $env, $extensions), + ]; + } +} diff --git a/src/Kernel/KernelInterface.php b/src/Kernel/KernelInterface.php index ac7684d..b693953 100644 --- a/src/Kernel/KernelInterface.php +++ b/src/Kernel/KernelInterface.php @@ -7,10 +7,12 @@ use SymPress\Kernel\Bundle\BundleInterface; use SymPress\Kernel\Bundle\BundleRegistry; use SymPress\Kernel\Container; +use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\KernelInterface as SymfonyKernelInterface; +use Symfony\Component\DependencyInjection\Kernel\KernelInterface as SymfonyKernelInterface; +use Symfony\Component\HttpKernel\HttpKernelInterface; -interface KernelInterface extends SymfonyKernelInterface +interface KernelInterface extends HttpKernelInterface, SymfonyKernelInterface { public function getProjectDir(): string; @@ -37,6 +39,11 @@ public function getBundles(): array; public function getBundle(string $name): BundleInterface; + /** @return iterable */ + public function registerBundles(): iterable; + + public function registerContainerConfiguration(LoaderInterface $loader): void; + public function locateResource(string $name): string; public function createContainer(): Container; diff --git a/src/Location/LocationResolver.php b/src/Location/LocationResolver.php index a7a6ca9..482dd2a 100644 --- a/src/Location/LocationResolver.php +++ b/src/Location/LocationResolver.php @@ -107,11 +107,14 @@ private function discoverVendorPath(): ?string private function resolve(string $location, string $dirOrUrl, ?string $subDir = null): ?string { - $envBase = (string) $this->config->get('WP_APP_' . strtoupper(sprintf('%s_%s', $location, $dirOrUrl))); + $configuredBase = $this->config->get('WP_APP_' . strtoupper(sprintf('%s_%s', $location, $dirOrUrl))); + $envBase = is_scalar($configuredBase) || $configuredBase instanceof \Stringable + ? (string) $configuredBase + : ''; $base = $envBase !== '' ? ($dirOrUrl === self::DIR ? wp_normalize_path($envBase) : $envBase) - : ($this->locations[$dirOrUrl][$location] ?? null); + : $this->locations[$dirOrUrl][$location] ?? null; if ($base === null && array_key_exists($location, self::CONTENT_LOCATIONS)) { $contentBase = $this->resolve(Locations::CONTENT, $dirOrUrl); @@ -147,7 +150,7 @@ private function locationsByConfig(EnvConfig $config): array return []; } - return $this->parseExtendedDefaults($locations); + return $this->parseExtendedDefaults($this->stringKeyMap($locations)); } /** @@ -178,4 +181,23 @@ private function parseExtendedDefaults(array $locations): array return array_filter($custom); } + + /** + * @param array $values + * @return array + */ + private function stringKeyMap(array $values): array + { + $map = []; + + foreach ($values as $key => $value) { + if (!is_string($key)) { + continue; + } + + $map[$key] = $value; + } + + return $map; + } } diff --git a/src/Location/VipLocations.php b/src/Location/VipLocations.php index 83bb256..a95e607 100644 --- a/src/Location/VipLocations.php +++ b/src/Location/VipLocations.php @@ -23,28 +23,32 @@ public static function createFromConfig(EnvConfig $config): Locations private function __construct(EnvConfig $config) { $baseResolver = new LocationResolver($config); - $contentUrl = $baseResolver->resolveUrl(self::CONTENT); - $contentDir = $baseResolver->resolveDir(self::CONTENT); + $contentUrl = $baseResolver->resolveUrl(self::CONTENT) ?? ''; + $contentDir = $baseResolver->resolveDir(self::CONTENT) ?? ''; $privateDir = defined('WPCOM_VIP_PRIVATE_DIR') - ? trailingslashit(wp_normalize_path((string) WPCOM_VIP_PRIVATE_DIR)) + ? trailingslashit(wp_normalize_path($this->constantString('WPCOM_VIP_PRIVATE_DIR'))) : null; $clientMuDir = defined('WPCOM_VIP_CLIENT_MU_PLUGIN_DIR') - ? trailingslashit(wp_normalize_path((string) WPCOM_VIP_CLIENT_MU_PLUGIN_DIR)) + ? trailingslashit(wp_normalize_path($this->constantString('WPCOM_VIP_CLIENT_MU_PLUGIN_DIR'))) : sprintf('%sclient-mu-plugins/', $contentDir); $clientMuUrl = sprintf('%sclient-mu-plugins/', $contentUrl); - $abspath = trailingslashit(wp_normalize_path((string) ABSPATH)); + $abspath = trailingslashit(wp_normalize_path($this->constantString('ABSPATH'))); + $directories = [ + self::IMAGES => sprintf('%simages/', $contentDir), + self::CLIENT_MU_PLUGINS => $clientMuDir, + self::VENDOR => sprintf('%svendor/', $clientMuDir), + self::VIP_CONFIG => sprintf('%svip-config/', $abspath), + ]; + + if ($privateDir !== null) { + $directories[self::PRIVATE] = $privateDir; + } $this->injectResolver( new LocationResolver( $config, [ - LocationResolver::DIR => [ - self::IMAGES => sprintf('%simages/', $contentDir), - self::CLIENT_MU_PLUGINS => $clientMuDir, - self::VENDOR => sprintf('%svendor/', $clientMuDir), - self::PRIVATE => $privateDir, - self::VIP_CONFIG => sprintf('%svip-config/', $abspath), - ], + LocationResolver::DIR => $directories, LocationResolver::URL => [ self::IMAGES => sprintf('%simages', $contentUrl), self::CLIENT_MU_PLUGINS => $clientMuUrl, @@ -55,6 +59,21 @@ private function __construct(EnvConfig $config) ); } + private function constantString(string $name): string + { + if (!defined($name)) { + return ''; + } + + $value = constant($name); + + if (is_scalar($value) || $value instanceof \Stringable) { + return (string) $value; + } + + return ''; + } + public function clientMuPluginsDir(string $path = '/'): ?string { return $this->resolveDir(self::CLIENT_MU_PLUGINS, $path); diff --git a/src/Package/PackageDiscovery.php b/src/Package/PackageDiscovery.php index 5b7266c..7a0f29f 100644 --- a/src/Package/PackageDiscovery.php +++ b/src/Package/PackageDiscovery.php @@ -80,10 +80,10 @@ private function package(string $packageName): ?PackageMetadata } $metadata = $this->composerMetadata($composerFile); - $kernel = $metadata['extra']['kernel'] ?? null; - $bundleClass = is_array($kernel) ? (string) ($kernel['bundle'] ?? '') : ''; - $entry = is_array($kernel) ? (string) ($kernel['entry'] ?? '') : ''; - $type = (string) ($metadata['type'] ?? ''); + $kernel = $this->kernelMetadata($metadata); + $bundleClass = $this->stringValue($kernel['bundle'] ?? null); + $entry = $this->stringValue($kernel['entry'] ?? null); + $type = $this->stringValue($metadata['type'] ?? null); if ($bundleClass === '' || $entry === '' || $type === '') { return null; @@ -127,9 +127,9 @@ private function displayData( ): array { $data = [ - 'name' => $this->humanName((string) ($metadata['name'] ?? '')), - 'description' => (string) ($metadata['description'] ?? ''), - 'version' => (string) ($metadata['version'] ?? ''), + 'name' => $this->humanName($this->stringValue($metadata['name'] ?? null)), + 'description' => $this->stringValue($metadata['description'] ?? null), + 'version' => $this->stringValue($metadata['version'] ?? null), ]; if ($type === 'wordpress-theme') { @@ -231,7 +231,7 @@ private function composerMetadata(string $composerFile): array $decoded = json_decode($contents, true); - $this->metadata[$composerFile] = is_array($decoded) ? $decoded : []; + $this->metadata[$composerFile] = is_array($decoded) ? $this->stringKeyMap($decoded) : []; return $this->metadata[$composerFile]; } @@ -357,9 +357,9 @@ private function requirementsActive(array $requirements): bool } $metadata = $this->composerMetadata($composerFile); - $kernel = $metadata['extra']['kernel'] ?? null; - $entry = is_array($kernel) ? (string) ($kernel['entry'] ?? '') : ''; - $type = (string) ($metadata['type'] ?? ''); + $kernel = $this->kernelMetadata($metadata); + $entry = $this->stringValue($kernel['entry'] ?? null); + $type = $this->stringValue($metadata['type'] ?? null); if ($entry === '' || $type === '') { return false; @@ -421,4 +421,49 @@ private function loadPluginAdminFunctions(): void require_once $file; } + + /** + * @param array $metadata + * @return array + */ + private function kernelMetadata(array $metadata): array + { + $extra = $metadata['extra'] ?? null; + + if (!is_array($extra)) { + return []; + } + + $kernel = $extra['kernel'] ?? null; + + return is_array($kernel) ? $this->stringKeyMap($kernel) : []; + } + + private function stringValue(mixed $value): string + { + if (is_scalar($value) || $value instanceof \Stringable) { + return (string) $value; + } + + return ''; + } + + /** + * @param array $values + * @return array + */ + private function stringKeyMap(array $values): array + { + $map = []; + + foreach ($values as $key => $value) { + if (!is_string($key)) { + continue; + } + + $map[$key] = $value; + } + + return $map; + } } diff --git a/src/Routing/RouteCompilerPass.php b/src/Routing/RouteCompilerPass.php index a89cb53..60612d7 100644 --- a/src/Routing/RouteCompilerPass.php +++ b/src/Routing/RouteCompilerPass.php @@ -21,8 +21,11 @@ public function process(ContainerBuilder $container): void return; } - $environment = $container->hasParameter('kernel.environment') - ? (string) $container->getParameter('kernel.environment') + $environmentParameter = $container->hasParameter('kernel.environment') + ? $container->getParameter('kernel.environment') + : null; + $environment = is_scalar($environmentParameter) || $environmentParameter instanceof \Stringable + ? (string) $environmentParameter : null; $frontendRoutes = []; $restRoutes = []; @@ -60,7 +63,49 @@ public function process(ContainerBuilder $container): void $definition->setArgument(2, $restRoutes); } - /** @return array{list>, list>} */ + /** + * @return array{ + * list, + * schemes: list, + * host: string, + * defaults: array, + * requirements: array, + * options: array, + * condition: string, + * priority: int, + * service: string, + * class: string, + * method: string + * }>, + * list, + * schemes: list, + * host: string, + * defaults: array, + * requirements: array, + * options: array, + * condition: string, + * priority: int, + * service: string, + * class: string, + * method: string, + * rest: array{ + * namespace: string, + * path: ?string, + * args: array>, + * permission_callback: mixed, + * public: bool, + * show_in_index: ?bool, + * override: bool + * } + * }> + * } + */ private function compiledRoutesForController(string $class, string $serviceId, ?string $environment): array { $frontendRoutes = []; @@ -153,12 +198,12 @@ private function definitionFromRoute(RouteCollection $collection, string $name, return [ 'name' => $name, 'path' => $route->getPath(), - 'methods' => array_values($route->getMethods()), - 'schemes' => array_values($route->getSchemes()), + 'methods' => $this->stringList($route->getMethods()), + 'schemes' => $this->stringList($route->getSchemes()), 'host' => $route->getHost(), - 'defaults' => $route->getDefaults(), - 'requirements' => $route->getRequirements(), - 'options' => $route->getOptions(), + 'defaults' => $this->stringKeyMap($route->getDefaults()), + 'requirements' => $this->stringMap($route->getRequirements()), + 'options' => $this->stringKeyMap($route->getOptions()), 'condition' => $route->getCondition(), 'priority' => $collection->getPriority($name) ?? 0, 'service' => $service, @@ -196,11 +241,7 @@ private function restDefinition(string $name, Route $route): array ); } - $args = $options['args'] ?? []; - - if (!is_array($args)) { - $args = []; - } + $args = $this->restArgs($options['args'] ?? []); $permissionCallback = $options['permission_callback'] ?? null; $public = ($options['public'] ?? false) === true; @@ -224,4 +265,81 @@ private function restDefinition(string $name, Route $route): array 'override' => (bool) ($options['override'] ?? false), ]; } + + /** + * @param array $values + * @return array + */ + private function stringKeyMap(array $values): array + { + $map = []; + + foreach ($values as $key => $value) { + if (!is_string($key)) { + continue; + } + + $map[$key] = $value; + } + + return $map; + } + + /** + * @param array $values + * @return array + */ + private function stringMap(array $values): array + { + $map = []; + + foreach ($values as $key => $value) { + if (!is_string($key)) { + continue; + } + + if (!is_scalar($value) && !$value instanceof \Stringable) { + continue; + } + + $map[$key] = (string) $value; + } + + return $map; + } + + /** @return list */ + private function stringList(mixed $values): array + { + if (!is_array($values)) { + return []; + } + + return array_values( + array_filter( + $values, + static fn (mixed $value): bool => is_string($value), + ), + ); + } + + /** @return array> */ + private function restArgs(mixed $args): array + { + if (!is_array($args)) { + return []; + } + + $normalized = []; + + foreach ($args as $name => $definition) { + if (!is_string($name) || !is_array($definition)) { + continue; + } + + $normalized[$name] = $this->stringKeyMap($definition); + } + + return $normalized; + } } diff --git a/src/Routing/RouteLoader.php b/src/Routing/RouteLoader.php index 30fb3bf..545f96e 100644 --- a/src/Routing/RouteLoader.php +++ b/src/Routing/RouteLoader.php @@ -15,13 +15,55 @@ use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; +/** + * @phpstan-type RestRouteDefinition array{ + * namespace: string, + * path: ?string, + * args: array>, + * permission_callback: mixed, + * public: bool, + * show_in_index: ?bool, + * override: bool + * } + * @phpstan-type CompiledRoute array{ + * name: string, + * path: string, + * methods: list, + * schemes: list, + * host: string, + * defaults: array, + * requirements: array, + * options: array, + * condition: string, + * priority: int, + * service: string, + * class: string, + * method: string + * } + * @phpstan-type CompiledRestRoute array{ + * name: string, + * path: string, + * methods: list, + * schemes: list, + * host: string, + * defaults: array, + * requirements: array, + * options: array, + * condition: string, + * priority: int, + * service: string, + * class: string, + * method: string, + * rest: RestRouteDefinition + * } + */ final class RouteLoader { public const string TAG = 'kernel.route_controller'; /** - * @param list> $routes - * @param list> $restRoutes + * @param list $routes + * @param list $restRoutes */ public function __construct( private readonly ContainerInterface $controllers, @@ -56,7 +98,7 @@ public function handle(Request $request): ?Response ); try { - $attributes = $matcher->matchRequest($request); + $attributes = $this->stringKeyMap($matcher->matchRequest($request)); } catch (ResourceNotFoundException) { return null; } catch (MethodNotAllowedException $exception) { @@ -82,6 +124,9 @@ public function registerRestRoutes(): void foreach ($this->restRoutes as $route) { $rest = $route['rest']; + $namespace = $this->nonFalsyString($rest['namespace'], $route['name'], 'namespace'); + $path = $this->nonFalsyString($this->restPath($route), $route['name'], 'path'); + $args = [ 'methods' => $route['methods'] === [] ? 'GET' : $route['methods'], 'callback' => $this->restCallback($route), @@ -94,8 +139,8 @@ public function registerRestRoutes(): void } register_rest_route( - $rest['namespace'], - $this->restPath($route), + $namespace, + $path, $args, (bool) ($rest['override'] ?? false), ); @@ -123,7 +168,7 @@ public function routeCollection(): RouteCollection return $collection; } - /** @return list> */ + /** @return list */ public function restRoutes(): array { return $this->restRoutes; @@ -141,27 +186,38 @@ private function invokeHttpController(array $attributes, Request $request): mixe $service = $this->controllers->get($serviceId); + if (!is_object($service)) { + $routeName = $attributes['_route'] ?? $serviceId; + $routeName = is_scalar($routeName) || $routeName instanceof \Stringable ? (string) $routeName : $serviceId; + + throw new \RuntimeException(sprintf('Route "%s" controller service is not an object.', $routeName)); + } + return $this->invokeController($service, $method, $request, $attributes); } - /** @param array $route */ + /** @param CompiledRestRoute $route */ private function restCallback(array $route): \Closure { return function (mixed $request = null) use ($route): mixed { $service = $this->controllers->get($route['service']); + if (!is_object($service)) { + throw new \RuntimeException(sprintf('REST route "%s" controller service is not an object.', $route['name'])); + } + return $this->invokeController($service, $route['method'], $request, $this->restRequestParameters($request)); }; } - /** @param array $route */ + /** @param CompiledRestRoute $route */ private function restPermissionCallback(array $route): callable { return function (mixed $request = null) use ($route): mixed { - $permissionCallback = $route['rest']['permission_callback'] ?? null; + $permissionCallback = $route['rest']['permission_callback']; if ($permissionCallback === null) { - if (($route['rest']['public'] ?? false) === true) { + if ($route['rest']['public']) { return true; } @@ -177,7 +233,7 @@ private function restPermissionCallback(array $route): callable if (is_string($permissionCallback)) { $service = $this->controllers->get($route['service']); - if (method_exists($service, $permissionCallback)) { + if (is_object($service) && method_exists($service, $permissionCallback)) { return $this->invokeController( $service, $permissionCallback, @@ -235,12 +291,15 @@ private function controllerArgument(\ReflectionParameter $parameter, mixed $requ return $attributes[$parameter->getName()]; } - if (is_object($request) && method_exists($request, 'has_param') && $request->has_param($parameter->getName())) { - return $request->get_param($parameter->getName()); + if ( + is_object($request) + && $this->callObjectMethod($request, 'has_param', $parameter->getName()) === true + ) { + return $this->callObjectMethod($request, 'get_param', $parameter->getName()); } - if (is_object($request) && method_exists($request, 'get_param')) { - $value = $request->get_param($parameter->getName()); + if (is_object($request)) { + $value = $this->callObjectMethod($request, 'get_param', $parameter->getName()); if ($value !== null) { return $value; @@ -285,6 +344,17 @@ private function parameterAcceptsObject(\ReflectionParameter $parameter, object return false; } + private function callObjectMethod(object $object, string $method, mixed ...$arguments): mixed + { + $callback = [$object, $method]; + + if (!is_callable($callback)) { + return null; + } + + return $callback(...$arguments); + } + private function normalizeResponse(mixed $value): Response { if ($value instanceof Response) { @@ -302,25 +372,25 @@ private function normalizeResponse(mixed $value): Response return new JsonResponse($value); } - /** @param array $definition */ + /** @param CompiledRoute|CompiledRestRoute $definition */ private function symfonyRoute(array $definition): Route { return new Route( $definition['path'], - $definition['defaults'] ?? [], - $definition['requirements'] ?? [], - $definition['options'] ?? [], - $definition['host'] ?? '', - $definition['schemes'] ?? [], - $definition['methods'] ?? [], - $definition['condition'] ?? '', + $definition['defaults'], + $definition['requirements'], + $definition['options'], + $definition['host'], + $definition['schemes'], + $definition['methods'], + $definition['condition'], ); } - /** @param array $route */ + /** @param CompiledRestRoute $route */ private function restPath(array $route): string { - $explicitPath = $route['rest']['path'] ?? null; + $explicitPath = $route['rest']['path']; if (is_string($explicitPath) && $explicitPath !== '') { return str_starts_with($explicitPath, '/') ? $explicitPath : '/' . $explicitPath; @@ -360,10 +430,41 @@ private function restRequestParameters(mixed $request): array $parameters = $request->get_url_params(); if (is_array($parameters)) { - return $parameters; + return $this->stringKeyMap($parameters); } } return []; } + + /** + * @param array $values + * @return array + */ + private function stringKeyMap(array $values): array + { + $map = []; + + foreach ($values as $key => $value) { + if (!is_string($key)) { + continue; + } + + $map[$key] = $value; + } + + return $map; + } + + /** @return non-falsy-string */ + private function nonFalsyString(string $value, string $routeName, string $field): string + { + if ($value === '' || $value === '0') { + throw new \RuntimeException( + sprintf('REST route "%s" must define a non-falsy %s.', $routeName, $field), + ); + } + + return $value; + } } diff --git a/src/WpContext.php b/src/WpContext.php index 55741e8..35936ac 100644 --- a/src/WpContext.php +++ b/src/WpContext.php @@ -237,8 +237,9 @@ private static function isLoginRequest(): bool } $scriptName = $_SERVER['SCRIPT_NAME'] ?? ''; + $scriptName = is_scalar($scriptName) || $scriptName instanceof \Stringable ? (string) $scriptName : ''; - return stripos(wp_login_url(), (string) $scriptName) !== false; + return $scriptName !== '' && stripos(wp_login_url(), $scriptName) !== false; } private static function isWpActivateRequest(): bool @@ -252,7 +253,8 @@ private static function isWpActivateRequest(): bool private static function isPageNow(string $page, string $url): bool { - $pageNow = (string) ($GLOBALS['pagenow'] ?? ''); + $pageNow = $GLOBALS['pagenow'] ?? ''; + $pageNow = is_scalar($pageNow) || $pageNow instanceof \Stringable ? (string) $pageNow : ''; if ($pageNow !== '' && basename($pageNow) === $page) { return true; diff --git a/tests/Admin/PackageManagerPageTest.php b/tests/Admin/PackageManagerPageTest.php index 5f2ba2d..a1afb85 100644 --- a/tests/Admin/PackageManagerPageTest.php +++ b/tests/Admin/PackageManagerPageTest.php @@ -137,6 +137,7 @@ function wp_clean_plugins_cache(bool $clearUpdateCache = true): void namespace SymPress\Kernel\Tests\Admin { use PHPUnit\Framework\TestCase; + use SymPress\Kernel\Admin\PackageManagerActions; use SymPress\Kernel\Admin\PackageManagerPage; use SymPress\Kernel\Package\PackageDiscovery; use SymPress\Kernel\Package\PackageMetadata; @@ -214,7 +215,7 @@ public function testUnmanagedSymlinkDeleteIsRejected(): void rmdir($link); symlink($target, $link); - $result = $this->invokePageMethod('deleteSymlinkPackage', $this->pluginPackage($link)); + $result = $this->invokeActionMethod('deleteSymlinkPackage', $this->pluginPackage($link)); self::assertInstanceOf(\WP_Error::class, $result); self::assertSame('kernel_package_unmanaged_symlink', $result->get_error_code()); @@ -235,7 +236,7 @@ public function testManagedSymlinkDeleteRemovesExpectedWordPressPackageLink(): v $this->paths[] = $contentDir; symlink($target, $link); - $result = $this->invokePageMethod('deleteSymlinkPackage', $this->pluginPackage($link)); + $result = $this->invokeActionMethod('deleteSymlinkPackage', $this->pluginPackage($link)); self::assertNull($result); self::assertFalse(is_link($link)); @@ -273,6 +274,13 @@ private function invokePageMethod(string $method, mixed ...$arguments): mixed return $reflection->invoke($this->page(true), ...$arguments); } + private function invokeActionMethod(string $method, mixed ...$arguments): mixed + { + $reflection = new \ReflectionMethod(PackageManagerActions::class, $method); + + return $reflection->invoke(new PackageManagerActions(), ...$arguments); + } + private function tmpPath(string $prefix): string { $path = sprintf('%s/%s-%s', sys_get_temp_dir(), $prefix, uniqid('', true)); diff --git a/tests/Kernel/BundleCompatibilityTest.php b/tests/Kernel/BundleCompatibilityTest.php index b5634d7..0f41401 100644 --- a/tests/Kernel/BundleCompatibilityTest.php +++ b/tests/Kernel/BundleCompatibilityTest.php @@ -169,6 +169,37 @@ public function testKernelLifecycleActionsAreDispatched(): void ); } + public function testDebugMethodsBridgeToOptionalProfilerService(): void + { + $profiler = new class { + public bool $enabled = false; + public bool $disabled = false; + + public function enable(): void + { + $this->enabled = true; + $this->disabled = false; + } + + public function disable(): void + { + $this->disabled = true; + $this->enabled = false; + } + }; + + $app = App::new(new LifecycleKernel($this->tmpPath('debug-project'), $profiler)); + $app->enableDebug()->boot(); + + self::assertTrue($profiler->enabled); + self::assertFalse($profiler->disabled); + + $app->disableDebug(); + + self::assertFalse($profiler->enabled); + self::assertTrue($profiler->disabled); + } + public function testKernelBootRethrowsErrorsAfterDispatchingErrorAction(): void { $kernel = new class ( diff --git a/tests/Kernel/LifecycleKernel.php b/tests/Kernel/LifecycleKernel.php index a0c8501..60f1310 100644 --- a/tests/Kernel/LifecycleKernel.php +++ b/tests/Kernel/LifecycleKernel.php @@ -22,6 +22,7 @@ final class LifecycleKernel implements KernelInterface public function __construct( private readonly string $projectDir, + private readonly ?object $profiler = null, ) { } @@ -136,6 +137,9 @@ public function configureContainer( Container $container, BundleRegistry $bundles, ): array { + if ($this->profiler !== null) { + $builder->set('profiler', $this->profiler); + } return []; } diff --git a/tests/Support/TestEnvironment.php b/tests/Support/TestEnvironment.php new file mode 100644 index 0000000..9e97fce --- /dev/null +++ b/tests/Support/TestEnvironment.php @@ -0,0 +1,28 @@ + $args + */ + public static function add_command(string $name, callable|object|string $callable, array $args = []): void + { + } + + public static function error(string $message): never + { + exit(1); + } + + public static function halt(int $status): never + { + exit($status); + } + } +}