diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c19bd4..4ae176d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,3 +12,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - feat: add shared framework-neutral normalization and presentation helpers for debug adapters. - feat: add validated collector contracts, lifecycle coordination, and isolated typed snapshot capture for adapters. - fix: guard the user-switch panel against concurrent submits so a double-click (or set + reset in the same tick) sends a single identity-switch request instead of racing two session regenerations. +- test: strengthen mutation coverage and clear PHPStan's result cache before static mutation analysis. diff --git a/composer.json b/composer.json index 5f8c8e8..ef96aa8 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,9 @@ "ext-intl": "*", "ext-mbstring": "*", "ui-awesome/html": "^0.6", + "ui-awesome/html-core-component": "^0.4", "ui-awesome/html-helper": "^0.7", + "ui-awesome/html-interop": "^0.4", "ui-awesome/html-svg": "^0.6" }, "require-dev": { @@ -73,6 +75,7 @@ "mutation": "php -d memory_limit=-1 vendor/bin/infection --threads=4 --ignore-msi-with-no-mutations --min-msi=100 --min-covered-msi=100", "mutation-static": [ "Composer\\Config::disableProcessTimeout", + "./vendor/bin/phpstan clear-result-cache", "php -d memory_limit=-1 vendor/bin/infection --threads=4 --ignore-msi-with-no-mutations --min-msi=100 --min-covered-msi=100 --static-analysis-tool=phpstan --static-analysis-tool-options='--memory-limit=-1'" ], "rector": "./vendor/bin/rector process", diff --git a/resources/views/history.php b/resources/views/history.php deleted file mode 100644 index e2e7a40..0000000 --- a/resources/views/history.php +++ /dev/null @@ -1,93 +0,0 @@ - $rows Normalized request history rows. - */ - -$heading = H1::tag()->class('yii-debug-sr-only')->content('Request history'); -$summary = Header::tag() - ->class('yii-debug-grid-summary') - ->html( - Span::tag()->html( - Strong::tag()->content((string) count($rows)), - ' Requests', - ), - ); - -if ($rows === []) { - $history = Div::tag()->class('yii-debug-empty-state')->content('No debug snapshots are available.'); -} else { - $body = Tbody::tag(); - - foreach ($rows as $row) { - $body = $body->tr( - Tr::tag() - ->addDataAttribute('yii-debug-tag', $row['tag']) - ->html( - Td::tag()->html( - A::tag() - ->class('yii-debug-tag-link') - ->content($row['tag']) - ->href($row['viewUrl']), - ), - Td::tag()->html( - Span::tag() - ->class('yii-debug-method yii-debug-verb-' . $row['methodVariant']) - ->content($row['method']), - ), - Td::tag()->html( - Span::tag() - ->class('yii-debug-badge yii-debug-status-' . $row['statusVariant']) - ->content((string) $row['statusCode']), - ), - Td::tag()->content($row['duration']), - Td::tag()->content($row['memory']), - Td::tag()->class('yii-debug-cell-nowrap')->content($row['capturedAt']), - Td::tag()->html( - A::tag() - ->class('yii-debug-url-cell') - ->content($row['requestUrl']) - ->href($row['viewUrl']) - ->title($row['requestUrl']), - ), - ), - ); - } - - $history = Div::tag() - ->class('yii-debug-table-wrap yii-debug-grid-history') - ->html( - Table::tag() - ->class('yii-debug-table') - ->thead( - Thead::tag()->tr( - Tr::tag()->headerCells('ID', 'Method', 'Status', 'Duration', 'Memory', 'Captured', 'URL'), - ), - ) - ->tbody($body), - ); -} -?> -render() ?> -render() ?> -render(); diff --git a/resources/views/sidebar.php b/resources/views/sidebar.php deleted file mode 100644 index d2c2c55..0000000 --- a/resources/views/sidebar.php +++ /dev/null @@ -1,89 +0,0 @@ - $navigation Panel navigation data. - */ - -$snapshotMeta = Div::tag()->class('yii-debug-snapshot-meta'); - -if ($snapshot['statusCode'] !== null) { - $snapshotMeta = $snapshotMeta->html( - Span::tag() - ->class('yii-debug-snapshot-status yii-debug-status-' . $snapshot['statusVariant']) - ->content((string) $snapshot['statusCode']), - ); -} - -if ($snapshot['time'] !== '') { - $snapshotMeta = $snapshotMeta->html(Span::tag()->content($snapshot['time'])); -} - -if ($snapshot['ajax']) { - $snapshotMeta = $snapshotMeta->html(Span::tag()->class('yii-debug-snapshot-tag')->content('AJAX')); -} - -$snapshotCard = Div::tag() - ->class('yii-debug-history-card') - ->html( - Div::tag() - ->class('yii-debug-snapshot-line') - ->html( - Span::tag() - ->class('yii-debug-snapshot-method yii-debug-verb-' . $snapshot['methodVariant']) - ->content($snapshot['method']), - Span::tag()->class('yii-debug-snapshot-url')->content($snapshot['url']), - ), - $snapshotMeta, - ) - ->title(trim($snapshot['method'] . ' ' . $snapshot['url'])); -$navigationList = Ul::tag(); - -foreach ($navigation as $item) { - $link = A::tag() - ->addAriaAttribute('current', $item['active'] ? 'page' : null) - ->class('yii-debug-nav-link' . ($item['active'] ? ' is-active' : '')) - ->href($item['url']) - ->html( - Span::tag() - ->addAriaAttribute('hidden', 'true') - ->class('yii-debug-nav-link-icon') - ->html($item['icon']), - Span::tag()->class('yii-debug-nav-link-label')->content($item['label']), - ); - $navigationList = $navigationList->html(Li::tag()->html($link)); -} - -$sidebar = Aside::tag() - ->class('yii-debug-sidebar') - ->html( - Section::tag() - ->class('yii-debug-side-section') - ->html( - Span::tag()->class('yii-debug-side-section-title')->content($snapshot['title']), - $snapshotCard, - ), - Nav::tag() - ->addAriaAttribute('label', 'Debugger panels') - ->class('yii-debug-nav yii-debug-nav-iconed') - ->html($navigationList), - ); -?> -render(); diff --git a/src/Helper/Dump.php b/src/Helper/Dump.php index 6cc8239..30454a8 100644 --- a/src/Helper/Dump.php +++ b/src/Helper/Dump.php @@ -10,6 +10,7 @@ use function get_debug_type; use function gettype; use function is_array; +use function is_scalar; use function range; use function str_repeat; use function var_export; @@ -79,7 +80,7 @@ private static function dumpArray(array $value, int $depth, int $level): string foreach (array_keys($value) as $key) { $output .= "\n{$spaces} "; - $output .= self::dumpInternal($key, $depth, 0); + $output .= self::dumpInternal($key, $depth, $level); $output .= ' => '; $output .= self::dumpInternal($value[$key], $depth, $level + 1); } @@ -126,7 +127,7 @@ private static function exportInternal(mixed $value, int $level): string $output .= "\n{$spaces} "; if ($outputKeys) { - $output .= self::exportInternal($key, 0); + $output .= self::exportInternal($key, $level); $output .= ' => '; } diff --git a/src/Panel/Asset/AssetCardRenderer.php b/src/Panel/Asset/AssetCardRenderer.php index 3c8c518..c5efd2f 100644 --- a/src/Panel/Asset/AssetCardRenderer.php +++ b/src/Panel/Asset/AssetCardRenderer.php @@ -51,21 +51,13 @@ public static function renderCard(AssetBundleView $bundle, AssetSummary $summary /** * Resolves the anchor id for a dependency name. * - * Prefers the id of an already-registered bundle so cross-references jump to a real card; otherwise falls back to a - * fresh {@see \\PHPForge\\Debug\\Helper\\Text::camel2id()} pass, so the link still points to the id the bundle - * would get if it were registered later. + * Uses the same canonical {@see \\PHPForge\\Debug\\Helper\\Text::camel2id()} conversion as bundle registration. * * @param string $depName Fully qualified class name of the dependency. - * @param AssetSummary $summary Already-normalized summary. + * @param AssetSummary $summary Already-normalized summary, retained for backward compatibility. */ public static function resolveAnchor(string $depName, AssetSummary $summary): string { - foreach ($summary->bundles as $candidate) { - if ($candidate->name === $depName) { - return $candidate->id; - } - } - return Text::camel2id($depName); } diff --git a/src/Panel/Db/DbQueryRenderer.php b/src/Panel/Db/DbQueryRenderer.php index 4559df0..7288190 100644 --- a/src/Panel/Db/DbQueryRenderer.php +++ b/src/Panel/Db/DbQueryRenderer.php @@ -15,8 +15,8 @@ use function date; use function implode; use function in_array; -use function mb_strtoupper; use function sprintf; +use function strtoupper; /** * Renders the typed cells of the queries grid for the DB debug panel. @@ -41,7 +41,7 @@ final class DbQueryRenderer public static function canBeExplained(string $type): bool { return in_array( - mb_strtoupper($type, 'utf8'), + strtoupper($type), ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'WITH'], true, ); diff --git a/src/Panel/Dump/DumpCardRenderer.php b/src/Panel/Dump/DumpCardRenderer.php index 24911ea..56fad07 100644 --- a/src/Panel/Dump/DumpCardRenderer.php +++ b/src/Panel/Dump/DumpCardRenderer.php @@ -14,9 +14,9 @@ use function array_map; use function basename; use function date; -use function floor; use function html_entity_decode; use function in_array; +use function intval; use function is_int; use function ltrim; use function preg_match; @@ -77,7 +77,7 @@ private static function formatTime(float $time): string return ''; } - $millis = (int) (($time - floor($time)) * 1000); + $millis = intval($time * 1000) % 1000; return date('H:i:s', (int) $time) . '.' . sprintf('%03d', $millis); } @@ -199,8 +199,8 @@ private static function sniffType(string $message): array return ['string', 'string']; } - if (preg_match('/^([A-Za-z_][A-Za-z0-9_\\\\]*)/', $payload, $m) === 1) { - $name = $m[1]; + if (preg_match('/^[A-Za-z_][A-Za-z0-9_\\\\]*/', $payload, $m) === 1) { + $name = $m[0]; $lower = strtolower($name); diff --git a/src/Panel/Log/LogCellRenderer.php b/src/Panel/Log/LogCellRenderer.php index b632f64..8e8fc67 100644 --- a/src/Panel/Log/LogCellRenderer.php +++ b/src/Panel/Log/LogCellRenderer.php @@ -16,6 +16,7 @@ use function array_map; use function date; use function implode; +use function intdiv; use function sprintf; use function str_starts_with; @@ -119,11 +120,11 @@ public static function renderMessageCell(LogRow $row, Closure $traceLine): strin */ public static function renderTimeCell(LogRow $row): string { - $seconds = $row->time / 1000; + $timestamp = (int) $row->time; + $seconds = intdiv($timestamp, 1000); + $millis = $timestamp % 1000; - $millis = (int) (($seconds - (int) $seconds) * 1000); - - return date('H:i:s.', (int) $seconds) . sprintf('%03d', $millis); + return date('H:i:s.', $seconds) . sprintf('%03d', $millis); } /** @@ -133,15 +134,13 @@ public static function renderTimeCell(LogRow $row): string */ public static function renderTimeSincePreviousCell(LogRow $row): string { - $diffMsTotal = $row->time - $row->timeOfPrevious; - - $diffSecondsTotal = $diffMsTotal / 1000; - $diffMinutesTotal = $diffSecondsTotal / 60; - $diffHoursTotal = $diffMinutesTotal / 60; - $diffMs = (int) $diffMsTotal % 1000; - $diffSeconds = (int) $diffSecondsTotal % 60; - $diffMinutes = (int) $diffMinutesTotal % 60; - $diffHours = (int) $diffHoursTotal; + $diffMsTotal = (int) ($row->time - $row->timeOfPrevious); + $diffSecondsTotal = intdiv($diffMsTotal, 1000); + $diffMinutesTotal = intdiv($diffSecondsTotal, 60); + $diffHours = intdiv($diffMinutesTotal, 60); + $diffMs = $diffMsTotal % 1000; + $diffSeconds = $diffSecondsTotal % 60; + $diffMinutes = $diffMinutesTotal % 60; $parts = []; diff --git a/src/Panel/Mail/MailCardRenderer.php b/src/Panel/Mail/MailCardRenderer.php index 9081904..0a41b15 100644 --- a/src/Panel/Mail/MailCardRenderer.php +++ b/src/Panel/Mail/MailCardRenderer.php @@ -17,12 +17,11 @@ use function array_map; use function date; use function explode; -use function floor; +use function intdiv; use function mb_strlen; use function mb_strtoupper; use function mb_substr; use function preg_replace; -use function time; /** * Renders the typed mail message card consumed by the Mail panel detail view's `_item` template. @@ -104,19 +103,19 @@ private static function formatTime(int $unix): array } if ($diff < 3600) { - $minutes = (int) floor($diff / 60); + $minutes = intdiv($diff, 60); return ["{$minutes} min ago", $absolute]; } if ($diff < 86400) { - $hours = (int) floor($diff / 3600); + $hours = intdiv($diff, 3600); return ["{$hours} h ago", $absolute]; } if ($diff < 2592000) { - $days = (int) floor($diff / 86400); + $days = intdiv($diff, 86400); return ["{$days} d ago", $absolute]; } diff --git a/src/Panel/Queue/QueueGridRenderer.php b/src/Panel/Queue/QueueGridRenderer.php index 8a6f545..de57944 100644 --- a/src/Panel/Queue/QueueGridRenderer.php +++ b/src/Panel/Queue/QueueGridRenderer.php @@ -11,6 +11,7 @@ use function abs; use function date; +use function intval; use function number_format; use function sprintf; @@ -143,8 +144,8 @@ public static function renderStatusCell(JobRecord $record): string */ public static function renderTimeCell(JobRecord $record): string { - $seconds = (int) $record->time; - $milliseconds = abs((int) (($record->time - $seconds) * 1000)); + $seconds = intval($record->time); + $milliseconds = abs(intval($record->time * 1000) % 1000); return date('H:i:s.', $seconds) . sprintf('%03d', $milliseconds); } diff --git a/src/Panel/User/UserDataNormalizer.php b/src/Panel/User/UserDataNormalizer.php index 2420f1c..1051b65 100644 --- a/src/Panel/User/UserDataNormalizer.php +++ b/src/Panel/User/UserDataNormalizer.php @@ -8,8 +8,8 @@ use function ctype_digit; use function date; -use function floor; use function in_array; +use function intdiv; use function is_array; use function mb_strtoupper; use function mb_substr; @@ -19,7 +19,6 @@ use function str_starts_with; use function strlen; use function substr; -use function time; use function trim; use function ucwords; @@ -94,7 +93,7 @@ private static function buildAttributes(array $bucket, array $labels, string $ki foreach ($bucket as $key => $value) { $display = self::stripQuotes($value); - $isEmpty = $display === '' || $value === 'null'; + $isEmpty = $display === ''; if ($isEmpty) { $rows[] = new UserAttribute( @@ -280,15 +279,15 @@ private static function humanTime(string $value): array if ($diff < 60) { $relative = 'just now'; } elseif ($diff < 3600) { - $minutes = floor($diff / 60); + $minutes = intdiv($diff, 60); $relative = "{$minutes} min ago"; } elseif ($diff < 86400) { - $hours = floor($diff / 3600); + $hours = intdiv($diff, 3600); $relative = "{$hours} h ago"; } elseif ($diff < 2592000) { - $days = floor($diff / 86400); + $days = intdiv($diff, 86400); $relative = "{$days} d ago"; } else { @@ -355,11 +354,11 @@ private static function resolveStatus(string $value): array */ private static function stripQuotes(string $value): string { - if ($value === 'null' || $value === '') { + if ($value === 'null') { return ''; } - if (str_starts_with($value, "'") && str_ends_with($value, "'") && strlen($value) > 1) { + if (str_starts_with($value, "'") && str_ends_with($value, "'")) { return substr($value, 1, -1); } diff --git a/src/PhpInfo/PhpInfoDataNormalizer.php b/src/PhpInfo/PhpInfoDataNormalizer.php index f5115d4..851fc18 100644 --- a/src/PhpInfo/PhpInfoDataNormalizer.php +++ b/src/PhpInfo/PhpInfoDataNormalizer.php @@ -20,8 +20,6 @@ use function is_string; use function mb_strlen; use function php_uname; -use function posix_getpwuid; -use function posix_getuid; use function preg_match; use function preg_match_all; use function preg_quote; @@ -32,7 +30,6 @@ use function str_contains; use function str_starts_with; use function strip_tags; -use function stripos; use function strlen; use function strpos; use function strtolower; @@ -64,7 +61,8 @@ final class PhpInfoDataNormalizer /** * Matches the key cell of a phpinfo row (`class="e"`), whether it is still a `` or already a row header. */ - private const string KEY_CELL_PATTERN = '%<(?:th|td)\b[^>]*class="[^"]*\be\b[^"]*"[^>]*>(.*?)%si'; + private const string KEY_CELL_PATTERN + = '%<(?:th|td)\b[^>]*class="[^"]*\be\b[^"]*"[^>]*>(?.*?)%si'; /** * Matches phpinfo's header row (``) anchored at the start of a table body. */ @@ -86,7 +84,7 @@ final class PhpInfoDataNormalizer /** * Matches a `` or `` and captures its content. */ - private const string TABLE_CELL_PATTERN = '%<(?:th|td)\b[^>]*>(.*?)%si'; + private const string TABLE_CELL_PATTERN = '%<(?:th|td)\b[^>]*>(?.*?)%si'; /** * Free-form key/value table ('Variable | Value', statistics, contributor lists). */ @@ -106,11 +104,11 @@ final class PhpInfoDataNormalizer /** * Matches a `` and captures its body. */ - private const string TABLE_PATTERN = '%]*>(.*?)
%si'; + private const string TABLE_PATTERN = '%]*>(?.*?)%si'; /** * Matches a `` and captures its attributes and its content. */ - private const string TABLE_ROW_PATTERN = '%]*)>(.*?)%si'; + private const string TABLE_ROW_PATTERN = '%[^>]*)>(?.*?)%si'; /** * Captures the {@see phpinfo()} report of the current process and narrows it into the typed {@see PhpInfoView}. @@ -189,7 +187,7 @@ public static function fromOutput( */ private static function addRowClass(string $attributes, string $class): string { - $withoutClass = (string) preg_replace(self::CLASS_ATTRIBUTE_PATTERN, '', trim($attributes)); + $withoutClass = preg_replace(self::CLASS_ATTRIBUTE_PATTERN, '', trim($attributes)); return trim("{$withoutClass} class=\"{$class}\""); } @@ -383,16 +381,19 @@ private static function buildTile(string $label, string $value, string $home): P if (str_contains($value, ',')) { $tokens = self::splitTrimmed($value, ','); - $isTokenList = count($tokens) > 1; - foreach ($tokens as $t) { - if ($t === '' || preg_match('/\s/', $t) === 1 || mb_strlen($t) > 32) { - $isTokenList = false; - break; + if (count($tokens) > 1) { + foreach ($tokens as $t) { + if (preg_match('/\s/', $t) === 1 || mb_strlen($t) > 32) { + return new PhpInfoTile( + label: $label, + displayValue: $value, + rawValue: $value, + kind: PhpInfoTile::KIND_TEXT, + ); + } } - } - if ($isTokenList) { $tokenDtos = array_map( static fn(string $t): PhpInfoToken => new PhpInfoToken(label: $t), $tokens, @@ -490,11 +491,11 @@ private static function countRowsWithValues(string $tableBody, bool $skipHeaderR $count = 0; foreach ($rows as $row) { - if ($skipHeaderRows && preg_match('/\bclass="[^"]*\bh\b[^"]*"/i', $row[1]) === 1) { + if ($skipHeaderRows && preg_match('/\bclass="[^"]*\bh\b[^"]*"/i', $row['attributes']) === 1) { continue; } - preg_match_all('%<(?:th|td)\b[^>]*>%i', $row[2], $cells); + preg_match_all('%<(?:th|td)\b[^>]*>%i', $row['body'], $cells); if (count($cells[0]) >= 2) { $count++; @@ -550,23 +551,23 @@ private static function extractCompactModule( preg_match_all(self::TABLE_PATTERN, $moduleBody, $tables, PREG_SET_ORDER); - if (count($tables) !== 1 || self::classifyModuleTable($tables[0][1]) !== self::TABLE_KIND_FACTS) { + if (count($tables) !== 1 || self::classifyModuleTable($tables[0]['body']) !== self::TABLE_KIND_FACTS) { return null; } - preg_match_all('%]*>(.*?)%si', $tables[0][1], $rows, PREG_SET_ORDER); + preg_match_all(self::TABLE_ROW_PATTERN, $tables[0]['body'], $rows, PREG_SET_ORDER); $tiles = []; foreach ($rows as $row) { - preg_match_all(self::TABLE_CELL_PATTERN, $row[1], $cells, PREG_SET_ORDER); + preg_match_all(self::TABLE_CELL_PATTERN, $row['body'], $cells, PREG_SET_ORDER); if (count($cells) < 2) { continue; } - $label = self::decodeCell($cells[0][1]); - $value = self::decodeCell($cells[1][1]); + $label = self::decodeCell($cells[0]['body']); + $value = self::decodeCell($cells[1]['body']); if ($label === '' || $value === '') { return null; @@ -595,13 +596,19 @@ private static function extractCompactModule( */ private static function extractFirstHeaderCells(string $tableBody): array { - if (preg_match('%]*class="[^"]*\bh\b[^"]*"[^>]*>(.*?)%si', $tableBody, $row) !== 1) { + if ( + preg_match( + '%]*class="[^"]*\bh\b[^"]*"[^>]*>(?.*?)%si', + $tableBody, + $row, + ) !== 1 + ) { return []; } - preg_match_all(self::TABLE_CELL_PATTERN, $row[1], $cells, PREG_SET_ORDER); + preg_match_all(self::TABLE_CELL_PATTERN, $row['body'], $cells, PREG_SET_ORDER); - return array_map(static fn(array $cell): string => self::decodeCell($cell[1]), $cells); + return array_map(static fn(array $cell): string => self::decodeCell($cell['body']), $cells); } /** @@ -615,7 +622,7 @@ private static function extractKeyCell(string $rowContent): string|null return null; } - return self::decodeCell($keyCell[1]); + return self::decodeCell($keyCell['body']); } /** @@ -668,7 +675,7 @@ private static function moduleBodyHasContent(string $moduleBody): bool preg_match_all(self::TABLE_PATTERN, $moduleBody, $tables, PREG_SET_ORDER); foreach ($tables as $table) { - $tableBody = $table[1]; + $tableBody = $table['body']; if (self::extractTableTitle($tableBody) !== '') { $tableBody = self::stripHeaderRow($tableBody); @@ -726,22 +733,22 @@ private static function normalizeFactRows(string $tableBody): string $normalized = preg_replace_callback( self::TABLE_ROW_PATTERN, static function (array $row): string { - preg_match_all(self::TABLE_CELL_PATTERN, $row[2], $cells, PREG_SET_ORDER); + preg_match_all(self::TABLE_CELL_PATTERN, $row['body'], $cells, PREG_SET_ORDER); if (count($cells) === 1) { - $content = trim($cells[0][1]); - $attributes = self::addRowClass($row[1], 'yii-debug-phpinfo-fact-subheading'); + $content = trim($cells[0]['body']); + $attributes = self::addRowClass($row['attributes'], 'yii-debug-phpinfo-fact-subheading'); return sprintf('%s', $attributes, $content); } - $value = isset($cells[1]) ? self::decodeCell($cells[1][1]) : ''; + $value = isset($cells[1]) ? self::decodeCell($cells[1]['body']) : ''; $class = mb_strlen($value) > 72 ? 'yii-debug-phpinfo-fact yii-debug-phpinfo-fact-wide' : 'yii-debug-phpinfo-fact'; - $attributes = self::addRowClass($row[1], $class); + $attributes = self::addRowClass($row['attributes'], $class); - return '' . self::renderFactStatusPills($row[2]) . ''; + return '' . self::renderFactStatusPills($row['body']) . ''; }, $tableBody, ); @@ -756,13 +763,13 @@ static function (array $row): string { * @param string $tableBody Inner HTML of the table. * @param string $labelOverride Label replacing the inferred one; empty string to keep the inferred label. * @param bool $collapsible Whether the chrome is a `
` disclosure instead of a plain `
`. - * @param bool $open Whether the disclosure starts expanded; ignored when `$collapsible` is `false`. + * @param bool|null $open Whether the disclosure starts expanded; `null` for non-collapsible tables. */ private static function normalizeModuleTable( string $tableBody, - string $labelOverride = '', - bool $collapsible = false, - bool $open = false, + string $labelOverride, + bool $collapsible, + bool|null $open, ): string { $tableTitle = self::extractTableTitle($tableBody); @@ -787,11 +794,12 @@ private static function normalizeModuleTable( $encodedLabel = Encode::content($label); $headTag = $collapsible ? 'summary' : 'header'; $containerTag = $collapsible ? 'details' : 'div'; + $isOpen = $open === true; $attributes = $collapsible ? sprintf( ' data-yii-debug-phpinfo-collapsible="true" data-yii-debug-phpinfo-default-open="%s"%s', - $open ? 'true' : 'false', - $open ? ' open' : '', + $isOpen ? 'true' : 'false', + $isOpen ? ' open' : '', ) : ''; @@ -840,13 +848,13 @@ private static function normalizeVariableTables(string $tableBody): string|null ]; foreach ($rows as $row) { - if (preg_match('/\bclass="[^"]*\bh\b[^"]*"/i', $row[1]) === 1) { + if (preg_match('/\bclass="[^"]*\bh\b[^"]*"/i', $row['attributes']) === 1) { $header = $row[0]; continue; } - $key = self::extractKeyCell($row[2]); + $key = self::extractKeyCell($row['body']); $groups[$key === null ? 'Other' : self::resolveVariableGroup($key)][] = $row[0]; } @@ -888,10 +896,6 @@ private static function parseOverviewRows(string $overviewSrc): array $key = trim(html_entity_decode(strip_tags($row[1]), ENT_QUOTES, 'UTF-8')); $value = trim(html_entity_decode(strip_tags($row[2]), ENT_QUOTES, 'UTF-8')); - if ($key === '' || stripos($key, 'PHP Logo') !== false) { - continue; - } - $rows[$key] = $value; } @@ -919,7 +923,7 @@ private static function redactSensitiveRows(string $tableBody): string $redacted = preg_replace_callback( self::TABLE_ROW_PATTERN, static function (array $row): string { - $key = self::extractKeyCell($row[2]); + $key = self::extractKeyCell($row['body']); if ($key === null || self::isSensitiveVariableKey($key) === false) { return $row[0]; @@ -928,10 +932,10 @@ static function (array $row): string { $content = preg_replace( '%]*)class="[^"]*\bv\b[^"]*"([^>]*)>.*?%si', 'redacted', - $row[2], + $row['body'], ); - return '' . ($content ?? $row[2]) . ''; + return '' . ($content ?? $row['body']) . ''; }, $tableBody, ); @@ -947,13 +951,13 @@ static function (array $row): string { private static function renderFactStatusPills(string $rowContent): string { $rendered = preg_replace_callback( - '%]*)>(.*?)%si', + '%[^>]*)>(?.*?)%si', static function (array $cell): string { - if (preg_match('/\bclass="[^"]*\bv\b[^"]*"/i', $cell[1]) !== 1) { + if (preg_match('/\bclass="[^"]*\bv\b[^"]*"/i', $cell['attributes']) !== 1) { return $cell[0]; } - $kind = self::resolveStatusVariant(self::decodeCell($cell[2])); + $kind = self::resolveStatusVariant(self::decodeCell($cell['body'])); if ($kind === null) { return $cell[0]; @@ -963,9 +967,9 @@ static function (array $cell): string { return sprintf( '%s', - $cell[1], + $cell['attributes'], $variant, - trim($cell[2]), + trim($cell['body']), ); }, $rowContent, @@ -1139,7 +1143,7 @@ private static function splitTrimmed(string $value, string $separator): array */ private static function stripHeaderRow(string $tableBody): string { - return preg_replace(self::LEADING_HEADER_ROW_PATTERN, '', $tableBody, 1) ?? $tableBody; + return preg_replace(self::LEADING_HEADER_ROW_PATTERN, '', $tableBody) ?? $tableBody; } /** @@ -1224,9 +1228,12 @@ private static function wrapModuleTables(string $modulesSrc, bool $redactSensiti $wrapped = preg_replace_callback( self::TABLE_PATTERN, static function (array $table) use ($redactSensitiveVariables): string { - $tableBody = $redactSensitiveVariables ? self::redactSensitiveRows($table[1]) : $table[1]; + $tableBody = $redactSensitiveVariables + ? self::redactSensitiveRows($table['body']) + : $table['body']; - return self::normalizeVariableTables($tableBody) ?? self::normalizeModuleTable($tableBody); + return self::normalizeVariableTables($tableBody) + ?? self::normalizeModuleTable($tableBody, '', false, null); }, $modulesSrc, ); diff --git a/src/Theme/ThemeResolver.php b/src/Theme/ThemeResolver.php new file mode 100644 index 0000000..53c15fd --- /dev/null +++ b/src/Theme/ThemeResolver.php @@ -0,0 +1,45 @@ +getCookieParams(), $request->getQueryParams()); + * ``` + */ +final class ThemeResolver +{ + /** + * Cookie written by the client-side theme toggle. + */ + public const string COOKIE = 'yii-debug-toolbar-theme'; + + /** + * Query parameter carrying the link-time theme snapshot. + */ + public const string QUERY_PARAM = 'yii_debug_theme'; + + /** + * Returns the effective theme (`'light'` or `'dark'`). + * + * @param array $cookieParams Request cookies. + * @param array $queryParams Parsed query parameters. + */ + public static function resolve(array $cookieParams, array $queryParams): string + { + $raw = $cookieParams[self::COOKIE] ?? $queryParams[self::QUERY_PARAM] ?? null; + + return is_string($raw) && strtolower($raw) === 'dark' ? 'dark' : 'light'; + } +} diff --git a/src/View/Grid/ActiveFilterBanner.php b/src/View/Grid/ActiveFilterBanner.php new file mode 100644 index 0000000..46d4514 --- /dev/null +++ b/src/View/Grid/ActiveFilterBanner.php @@ -0,0 +1,100 @@ + '404'], + * static fn(array $without): string => '/debug?cleared=1', + * ); + * ``` + */ +final class ActiveFilterBanner +{ + /** + * Returns the rendered banner HTML, or an empty string when no filters are active. + * + * @param array $activeFilters Attribute-to-value map of the currently applied filters. + * @param Closure(list): string $removeUrl Builds the link that drops the given attributes from the URL. + */ + public static function render(array $activeFilters, Closure $removeUrl): string + { + if ($activeFilters === []) { + return ''; + } + + $count = count($activeFilters); + + $pills = ''; + + foreach ($activeFilters as $attr => $val) { + $attribute = Span::tag() + ->class('yii-debug-active-filter-attr') + ->content($attr) + ->render(); + $separator = Span::tag() + ->class('yii-debug-active-filter-sep') + ->content(':') + ->render(); + $value = Span::tag() + ->class('yii-debug-active-filter-value') + ->content($val) + ->render(); + $remove = Span::tag() + ->class('yii-debug-active-filter-x') + ->addAttribute('aria-hidden', 'true') + ->content('×') + ->render(); + + $pillContent = "{$attribute}{$separator}{$value}{$remove}"; + + $pills .= A::tag() + ->class('yii-debug-active-filter-pill') + ->addAttribute('title', 'Remove this filter') + ->href($removeUrl([$attr])) + ->html($pillContent) + ->render(); + } + + $label = Span::tag() + ->class('yii-debug-active-filters-label') + ->content($count . ' filter' . ($count === 1 ? '' : 's') . ' active') + ->render(); + + $list = Span::tag()->class('yii-debug-active-filters-list')->html($pills)->render(); + + $clearAll = A::tag() + ->class('yii-debug-active-filters-clear') + ->addAttribute('title', 'Clear all filters and show every row') + ->href($removeUrl(array_keys($activeFilters))) + ->content('Clear all') + ->render(); + + $content = "{$label}{$list}{$clearAll}"; + + return Div::tag() + ->class('yii-debug-active-filters') + ->addAttribute('role', 'group') + ->addAriaAttribute('label', 'Active filters') + ->html($content) + ->render(); + } +} diff --git a/src/View/Grid/RowClass.php b/src/View/Grid/RowClass.php new file mode 100644 index 0000000..ee30b7b --- /dev/null +++ b/src/View/Grid/RowClass.php @@ -0,0 +1,39 @@ +` row classes used by the debug grids. + */ +final class RowClass +{ + /** + * Returns the row-attributes array carrying the `yii-debug-row-` CSS class for the given status level. + * + * Accepts `success`, `info`, `warning`, `danger`, and `error` (aliased to `danger`). Unknown or empty levels yield + * an empty array, so the caller can splat the result safely. + * + * Usage example: + * ```php + * $attributes = \PHPForge\Debug\View\Grid\RowClass::for('danger'); + * ``` + * + * @param string|null $level Status keyword, or `null` to skip the class. + * + * @return array Row-attributes array with the `class` key set, or `[]` for unknown/`null` levels. + */ + public static function for(string|null $level): array + { + $normalized = $level === 'error' ? 'danger' : $level; + + if (!in_array($normalized, ['success', 'info', 'warning', 'danger'], true)) { + return []; + } + + return ['class' => 'yii-debug-row-' . $normalized]; + } +} diff --git a/src/View/History/HistoryCellRenderer.php b/src/View/History/HistoryCellRenderer.php new file mode 100644 index 0000000..f2f0b62 --- /dev/null +++ b/src/View/History/HistoryCellRenderer.php @@ -0,0 +1,267 @@ +`). + * Link targets are pre-built URL strings supplied by the adapter, so the renderer stays framework-neutral. + */ +final class HistoryCellRenderer +{ + /** + * Builds the row-attributes map for one captured-request row. The `data-*` attributes feed the sidebar's history + * cursor. + * + * @param HistoryRow $row Typed history row. + * @param bool $isCritical Whether the row's status code counts as critical (adds the danger row class). + * + * @return array + */ + public static function buildRowAttributes(HistoryRow $row, bool $isCritical): array + { + $base = $isCritical ? RowClass::for('danger') : []; + + $base['data-yii-debug-tag'] = $row->tag; + $base['data-yii-debug-method'] = $row->method; + $base['data-yii-debug-url'] = $row->url; + $base['data-yii-debug-status'] = (string) $row->statusCode; + $base['data-yii-debug-time'] = $row->timeCompact; + $base['data-yii-debug-ajax'] = $row->ajax ? '1' : ''; + + return $base; + } + + /** + * Renders the AJAX column cell (`'Yes'` / `'No'`). + */ + public static function renderAjaxCell(HistoryRow $row): string + { + return $row->ajax ? 'Yes' : 'No'; + } + + /** + * Renders the duration column cell (`'X ms'` or `'(not set)'` muted placeholder when missing), with a micro-gauge + * rail scaled against the page maximum when one exists. + * + * @param HistoryRow $row Typed history row. + * @param float $maxProcessingTime Page maximum in seconds ({@see HistoryScale::$maxProcessingTime}). + */ + public static function renderDurationCell(HistoryRow $row, float $maxProcessingTime): string + { + if ($row->processingTime === null) { + return Span::tag() + ->class('yii-debug-not-set') + ->content('(not set)') + ->render(); + } + + return Gauge::render( + number_format($row->processingTime * 1000) . ' ms', + $row->processingTime, + $maxProcessingTime, + ); + } + + /** + * Renders the memory column cell (`'X.XXX MB'` or `'(not set)'`), with a micro-gauge rail scaled against the page + * maximum when one exists. + * + * @param HistoryRow $row Typed history row. + * @param int $maxPeakMemory Page maximum in bytes ({@see HistoryScale::$maxPeakMemory}). + */ + public static function renderMemoryCell(HistoryRow $row, int $maxPeakMemory): string + { + if ($row->peakMemory === null) { + return Span::tag() + ->class('yii-debug-not-set') + ->content('(not set)') + ->render(); + } + + return Gauge::render( + Format::bytesToMb($row->peakMemory, 3), + (float) $row->peakMemory, + (float) $maxPeakMemory, + ); + } + + /** + * Renders the method column cell as vocabulary-colored text, or an empty string when the method was not captured. + */ + public static function renderMethodCell(HistoryRow $row): string + { + if ($row->method === '') { + return ''; + } + + return Span::tag() + ->class('yii-debug-method yii-debug-verb-' . Vocabulary::verb($row->method)) + ->content($row->method) + ->render(); + } + + /** + * Renders the SQL-query column cell (count + warning chip + deep-link to the DB panel). + * + * @param HistoryRow $row Typed history row. + * @param string $url Pre-built link target to the DB panel view for this request. + * @param bool $isQueryCountCritical Whether the row's query count exceeds the critical threshold. + * @param int $criticalQueryThreshold Threshold surfaced in the warning tooltip when the count is critical. + */ + public static function renderSqlCountCell( + HistoryRow $row, + string $url, + bool $isQueryCountCritical, + int $criticalQueryThreshold, + ): string { + $title = "Executed {$row->sqlCount} database queries."; + + $warningParts = []; + + if ($isQueryCountCritical) { + $warningParts[] = "Too many queries. Allowed count is {$criticalQueryThreshold}"; + } + + if ($row->excessiveCallersCount > 0) { + $callerLabel = $row->excessiveCallersCount === 1 ? 'caller is' : 'callers are'; + $warningParts[] = "{$row->excessiveCallersCount} {$callerLabel} making too many calls."; + } + + $warning = implode(' ', $warningParts); + + $content = (string) $row->sqlCount; + + if ($warning !== '') { + $warningHtml = Span::tag() + ->title($warning) + ->content('⚠') + ->render(); + + $content = "{$content} {$warningHtml}"; + } + + return A::tag() + ->href($url) + ->title($title) + ->html($content) + ->render(); + } + + /** + * Renders the status-code badge cell; an uncaptured (`0`) code displays as a successful `200`. + */ + public static function renderStatusCell(HistoryRow $row): string + { + $statusCode = $row->statusCode === 0 ? 200 : $row->statusCode; + + return Span::tag() + ->class('yii-debug-badge yii-debug-status-' . Vocabulary::statusClass($statusCode)) + ->content((string) $statusCode) + ->render(); + } + + /** + * Renders the summary header (`
`) with the request total and the + * status-bucket pills. + * + * @param HistorySummary $summary Typed summary aggregate. + * @param array $bucketUrls Bucket-label-to-URL map for the pill deep links (missing labels render + * without an `href`). + * @param string $pageSizeHtml Pre-rendered page-size selector markup supplied by the adapter. + */ + public static function renderSummary(HistorySummary $summary, array $bucketUrls, string $pageSizeHtml): string + { + if ($summary->totalRequests === 0) { + return ''; + } + + $requestLabel = $summary->totalRequests === 1 ? 'captured request' : 'captured requests'; + + $children = [ + Span::tag()->html( + Strong::tag()->content((string) $summary->totalRequests), + " {$requestLabel}", + ), + ]; + + foreach ($summary->statusBuckets as $bucket) { + $children[] = Span::tag() + ->class('yii-debug-grid-summary-sep') + ->content('·'); + $children[] = A::tag() + ->class("yii-debug-grid-summary-stat-{$bucket->variant}") + ->href($bucketUrls[$bucket->label] ?? '') + ->title("Filter to {$bucket->label} responses (sample {$bucket->sampleCode})") + ->html(Strong::tag()->content((string) $bucket->count), " {$bucket->label}"); + } + + return Header::tag() + ->class('yii-debug-grid-summary') + ->html(...$children, ...[$pageSizeHtml]) + ->render(); + } + + /** + * Renders the request-tag column cell as a link to the panel view. + * + * @param HistoryRow $row Typed history row. + * @param string $url Pre-built link target to the request's panel view. + */ + public static function renderTagCell(HistoryRow $row, string $url): string + { + return A::tag() + ->class('yii-debug-tag-link') + ->href($url) + ->content($row->tag) + ->render(); + } + + /** + * Renders the time column cell — compact `HH:MM:SS` with a full `Y-m-d H:i:s` tooltip on hover. + */ + public static function renderTimeCell(HistoryRow $row): string + { + if ($row->time === 0.0) { + return Span::tag() + ->class('yii-debug-not-set') + ->content('(not set)') + ->render(); + } + + $timestamp = (int) $row->time; + + return Span::tag() + ->class('yii-debug-nowrap') + ->title(date('Y-m-d H:i:s', $timestamp)) + ->content(date('H:i:s', $timestamp)) + ->render(); + } + + /** + * Renders the URL column cell with a hover-truncate wrapper. + */ + public static function renderUrlCell(HistoryRow $row): string + { + return Span::tag() + ->class('yii-debug-url-cell') + ->title($row->url) + ->content($row->url) + ->render(); + } +} diff --git a/src/View/History/HistoryRow.php b/src/View/History/HistoryRow.php new file mode 100644 index 0000000..057faa7 --- /dev/null +++ b/src/View/History/HistoryRow.php @@ -0,0 +1,95 @@ +tag, + method: $summary->method, + url: $summary->url, + statusCode: $summary->statusCode, + time: $summary->time, + timeCompact: $summary->time > 0 ? date('H:i:s', (int) $summary->time) : '', + processingTime: $summary->processingTime, + peakMemory: $summary->peakMemory, + ip: $summary->ip, + sqlCount: $summary->sqlCount, + mailCount: $summary->mailCount, + excessiveCallersCount: $summary->excessiveCallersCount, + ajax: $summary->ajax, + ); + } +} diff --git a/src/View/History/HistoryScale.php b/src/View/History/HistoryScale.php new file mode 100644 index 0000000..b68fa23 --- /dev/null +++ b/src/View/History/HistoryScale.php @@ -0,0 +1,53 @@ + $models Rows as supplied by the data provider. + */ + public static function fromModels(array $models): self + { + $maxProcessingTime = 0.0; + $maxPeakMemory = 0; + + foreach ($models as $row) { + if ($row->processingTime !== null) { + $maxProcessingTime = max($maxProcessingTime, $row->processingTime); + } + + if ($row->peakMemory !== null) { + $maxPeakMemory = max($maxPeakMemory, $row->peakMemory); + } + } + + return new self( + maxProcessingTime: $maxProcessingTime, + maxPeakMemory: $maxPeakMemory, + ); + } +} diff --git a/src/View/History/HistoryStatusBucket.php b/src/View/History/HistoryStatusBucket.php new file mode 100644 index 0000000..d308b7f --- /dev/null +++ b/src/View/History/HistoryStatusBucket.php @@ -0,0 +1,31 @@ + + */ + public array $statusBuckets, + /** + * Unique status-code map (`code => code`) consumed by the grid's status filter dropdown; `null` when the + * manifest has no captured statuses (the dropdown collapses to a text input). + * + * @var array|null + */ + public array|null $statusCodeFilter, + ) {} + + /** + * Builds the typed summary from the request manifest. + * + * @param array $manifest Manifest entries; only the values are read. + */ + public static function fromManifest(array $manifest): self + { + $totalRequests = count($manifest); + + $buckets = ['2xx' => 0, '3xx' => 0, '4xx' => 0, '5xx' => 0]; + $sample = []; + $codes = []; + + foreach ($manifest as $entry) { + $statusCode = $entry->statusCode; + + if ($statusCode > 0) { + $codes[$statusCode] = $statusCode; + } + + if ($statusCode < 200 || $statusCode >= 600) { + continue; + } + + $bucket = match (true) { + $statusCode >= 500 => '5xx', + $statusCode >= 400 => '4xx', + $statusCode >= 300 => '3xx', + default => '2xx', + }; + + $buckets[$bucket]++; + $sample[$bucket] ??= $statusCode; + } + + $statusBuckets = []; + + foreach ($buckets as $label => $count) { + if (!isset($sample[$label])) { + continue; + } + + $statusBuckets[] = new HistoryStatusBucket( + label: $label, + count: $count, + sampleCode: $sample[$label], + variant: $label, + ); + } + + ksort($codes); + + return new self( + totalRequests: $totalRequests, + statusBuckets: $statusBuckets, + statusCodeFilter: $codes === [] ? null : $codes, + ); + } +} diff --git a/src/View/Sidebar/SidebarNavItem.php b/src/View/Sidebar/SidebarNavItem.php new file mode 100644 index 0000000..30b28dd --- /dev/null +++ b/src/View/Sidebar/SidebarNavItem.php @@ -0,0 +1,38 @@ +`. + */ + public bool $isActive, + ) {} +} diff --git a/src/View/Sidebar/SidebarRenderer.php b/src/View/Sidebar/SidebarRenderer.php new file mode 100644 index 0000000..6a2f382 --- /dev/null +++ b/src/View/Sidebar/SidebarRenderer.php @@ -0,0 +1,278 @@ +` with the snapshot card + panel nav). + * + * Usage example: + * ```php + * $html = \PHPForge\Debug\View\Sidebar\SidebarRenderer::render($view); + * ``` + */ + public static function render(SidebarView $view): string + { + $children = []; + + if ($view->snapshot !== null) { + $children[] = self::renderSnapshotSection($view->snapshot); + } + + $children[] = self::renderPanelNav($view->navItems); + + return Aside::tag() + ->class('yii-debug-sidebar') + ->html(...$children) + ->render(); + } + + /** + * Renders the snapshot card body (method/url line + meta strip + navigator row). + */ + private static function renderHistoryCard(SidebarSnapshot $snapshot): Div + { + $method = $snapshot->method !== '' ? "{$snapshot->method} " : ''; + + return Div::tag() + ->class('yii-debug-history-card') + ->title("{$method}{$snapshot->fullUrl}") + ->html( + Div::tag() + ->class('yii-debug-snapshot-line') + ->html( + Span::tag() + ->class('yii-debug-snapshot-method yii-debug-verb-' . Vocabulary::verb($snapshot->method)) + ->addDataAttribute('snapshot-field', 'method') + ->content($snapshot->method), + Span::tag() + ->class('yii-debug-snapshot-url') + ->addDataAttribute('snapshot-field', 'url') + ->title($snapshot->fullUrl) + ->content($snapshot->path), + ), + self::renderMetaStrip($snapshot), + self::renderNavRow($snapshot), + ); + } + + /** + * Renders the snapshot card meta strip (status pill + time chip + AJAX tag). + */ + private static function renderMetaStrip(SidebarSnapshot $snapshot): Div + { + $time = Span::tag() + ->class('yii-debug-snapshot-time') + ->addDataAttribute('snapshot-field', 'time') + ->content($snapshot->time); + + if ($snapshot->time === '') { + $time = $time->addAttribute('hidden', true); + } + + $ajax = Span::tag() + ->class('yii-debug-snapshot-tag') + ->addDataAttribute('snapshot-field', 'ajax') + ->content('AJAX'); + + if ($snapshot->isAjax === false) { + $ajax = $ajax->addAttribute('hidden', true); + } + + return Div::tag() + ->class('yii-debug-snapshot-meta') + ->html( + Span::tag() + ->class('yii-debug-snapshot-status yii-debug-status-' . $snapshot->statusVariant) + ->addDataAttribute('snapshot-field', 'status') + ->content($snapshot->statusCode > 0 ? (string) $snapshot->statusCode : '–'), + $time, + $ajax, + ); + } + + /** + * Renders one navigator button (either an anchor link or a cursor `