From 42d145e4db6fccb746a18465d155b1c039ee25bd Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 11 May 2026 23:09:28 +0200 Subject: [PATCH 01/37] cs --- src/Bridges/Nette/TracyExtension.php | 23 ++++++++++--------- src/Tracy/Bar/dist/dumps.agent.phtml | 1 + src/Tracy/Bar/dist/info.panel.phtml | 6 ++--- src/Tracy/Bar/dist/warnings.agent.phtml | 1 + src/Tracy/Bar/dist/warnings.panel.phtml | 2 +- src/Tracy/Bar/panels/info.panel.latte | 6 ++--- src/Tracy/Bar/panels/warnings.panel.latte | 2 +- src/Tracy/BlueScreen/BlueScreen.php | 4 ++-- src/Tracy/BlueScreen/CodeHighlighter.php | 2 +- src/Tracy/BlueScreen/assets/agent.latte | 2 +- src/Tracy/BlueScreen/assets/page.latte | 4 ++-- .../BlueScreen/assets/section-header.latte | 2 +- src/Tracy/BlueScreen/dist/agent.phtml | 2 +- src/Tracy/BlueScreen/dist/page.phtml | 4 ++-- .../BlueScreen/dist/section-header.phtml | 2 +- src/Tracy/Debugger/Debugger.php | 2 +- src/Tracy/Debugger/DeferredContent.php | 2 +- src/Tracy/Dumper/Describer.php | 4 ++-- src/Tracy/Dumper/Exposer.php | 2 +- src/Tracy/Logger/Logger.php | 2 +- tests/Tracy/Bar.renderAgent().phpt | 4 +--- tests/Tracy/BlueScreen.renderAgent().phpt | 4 +--- tests/Tracy/Helpers.isAgent().phpt | 4 +--- 23 files changed, 42 insertions(+), 45 deletions(-) diff --git a/src/Bridges/Nette/TracyExtension.php b/src/Bridges/Nette/TracyExtension.php index 1dfeb7965..c6a8f7aa7 100644 --- a/src/Bridges/Nette/TracyExtension.php +++ b/src/Bridges/Nette/TracyExtension.php @@ -89,7 +89,7 @@ public function afterCompile(Nette\PhpGenerator\ClassType $class): void $initialize->addBody($builder->formatPhp('$logger = ?;', [$logger])); if ( !$logger instanceof Nette\DI\Definitions\ServiceDefinition - || $logger->getFactory()->getEntity() !== [Tracy\Debugger::class, 'getLogger'] + || $logger->getEntity() !== [Tracy\Debugger::class, 'getLogger'] ) { $initialize->addBody('Tracy\Debugger::setLogger($logger);'); } @@ -103,19 +103,20 @@ public function afterCompile(Nette\PhpGenerator\ClassType $class): void } } + $special = [ + 'keysToHide' => <<<'XX' + $keysToHide = ?; + array_push(Tracy\Debugger::$keysToHide, ...$keysToHide); + array_push(Tracy\Debugger::getBlueScreen()->keysToHide, ...$keysToHide); + XX, + 'fromEmail' => 'if ($logger instanceof Tracy\Logger) $logger->fromEmail = ?', + 'emailSnooze' => 'if ($logger instanceof Tracy\Logger) $logger->emailSnooze = ?', + ]; + foreach ($options as $key => $value) { if ($value !== null) { - $tbl = [ - 'keysToHide' => <<<'XX' - $keysToHide = ?; - array_push(Tracy\Debugger::$keysToHide, ...$keysToHide); - array_push(Tracy\Debugger::getBlueScreen()->keysToHide, ...$keysToHide); - XX, - 'fromEmail' => 'if ($logger instanceof Tracy\Logger) $logger->fromEmail = ?', - 'emailSnooze' => 'if ($logger instanceof Tracy\Logger) $logger->emailSnooze = ?', - ]; $initialize->addBody($builder->formatPhp( - ($tbl[$key] ?? 'Tracy\Debugger::$' . $key . ' = ?') . ';', + ($special[$key] ?? 'Tracy\Debugger::$' . $key . ' = ?') . ';', Nette\DI\Helpers::filterArguments([$value]), )); } diff --git a/src/Tracy/Bar/dist/dumps.agent.phtml b/src/Tracy/Bar/dist/dumps.agent.phtml index ea5e8ea59..23d1512a9 100644 --- a/src/Tracy/Bar/dist/dumps.agent.phtml +++ b/src/Tracy/Bar/dist/dumps.agent.phtml @@ -17,3 +17,4 @@ foreach ($data as $item) /* pos 5:1 */ { echo "\n"; } + diff --git a/src/Tracy/Bar/dist/info.panel.phtml b/src/Tracy/Bar/dist/info.panel.phtml index e026ff40c..e87dfa97c 100644 --- a/src/Tracy/Bar/dist/info.panel.phtml +++ b/src/Tracy/Bar/dist/info.panel.phtml @@ -56,7 +56,7 @@ if ($packages || $devPackages) /* pos 35:3 */ { echo '

Composer Packages ('; echo Tracy\Helpers::escapeHtml(count($packages)) /* pos 37:24 */; - echo Tracy\Helpers::escapeHtml($devPackages ? ' + ' . count($devPackages) . ' dev' : '') /* pos 37:42 */; + echo Tracy\Helpers::escapeHtml($devPackages ? ' + ' . count($devPackages) . ' dev' : null) /* pos 37:42 */; echo ')

@@ -72,7 +72,7 @@ if ($packages || $devPackages) /* pos 35:3 */ { echo ' '; echo Tracy\Helpers::escapeHtml($package->version) /* pos 44:11 */; - echo Tracy\Helpers::escapeHtml(strpos($package->version, 'dev') !== false && $package->hash ? ' #' . substr($package->hash, 0, 4) : '') /* pos 44:30 */; + echo Tracy\Helpers::escapeHtml(strpos($package->version, 'dev') !== false && $package->hash ? ' #' . substr($package->hash, 0, 4) : null) /* pos 44:30 */; echo ' '; @@ -95,7 +95,7 @@ if ($packages || $devPackages) /* pos 35:3 */ { echo ' '; echo Tracy\Helpers::escapeHtml($package->version) /* pos 54:12 */; - echo Tracy\Helpers::escapeHtml(strpos($package->version, 'dev') !== false && $package->hash ? ' #' . substr($package->hash, 0, 4) : '') /* pos 54:31 */; + echo Tracy\Helpers::escapeHtml(strpos($package->version, 'dev') !== false && $package->hash ? ' #' . substr($package->hash, 0, 4) : null) /* pos 54:31 */; echo ' '; diff --git a/src/Tracy/Bar/dist/warnings.agent.phtml b/src/Tracy/Bar/dist/warnings.agent.phtml index ba0292e8b..2eda20ace 100644 --- a/src/Tracy/Bar/dist/warnings.agent.phtml +++ b/src/Tracy/Bar/dist/warnings.agent.phtml @@ -12,3 +12,4 @@ foreach ($data as $item => $count) /* pos 5:1 */ { echo "\n"; } + diff --git a/src/Tracy/Bar/dist/warnings.panel.phtml b/src/Tracy/Bar/dist/warnings.panel.phtml index 78b6c0ed5..fd722292a 100644 --- a/src/Tracy/Bar/dist/warnings.panel.phtml +++ b/src/Tracy/Bar/dist/warnings.panel.phtml @@ -10,7 +10,7 @@ foreach ($data as $item => $count) /* pos 6:3 */ { [$file, $line, $message] = explode('|', $item, 3) /* pos 7:4 */; echo ' '; - echo Tracy\Helpers::escapeHtml($count ? $count . '×' : '') /* pos 9:29 */; + echo Tracy\Helpers::escapeHtml($count ? $count . '×' : null) /* pos 9:29 */; echo '
';
 	echo Tracy\Helpers::escapeHtml($message) /* pos 10:14 */;
diff --git a/src/Tracy/Bar/panels/info.panel.latte b/src/Tracy/Bar/panels/info.panel.latte
index d2335706e..747857ae1 100644
--- a/src/Tracy/Bar/panels/info.panel.latte
+++ b/src/Tracy/Bar/panels/info.panel.latte
@@ -34,14 +34,14 @@
 
 		{if $packages || $devPackages}
 			

- Composer Packages ({count($packages)}{$devPackages ? ' + ' . count($devPackages) . ' dev' : ''}) + Composer Packages ({count($packages)}{$devPackages ? ' + ' . count($devPackages) . ' dev'})

- +
{$package->name}{$package->version}{strpos($package->version, dev) !== false && $package->hash ? ' #' . substr($package->hash, 0, 4) : ''}{$package->version}{strpos($package->version, dev) !== false && $package->hash ? ' #' . substr($package->hash, 0, 4)}
@@ -51,7 +51,7 @@ - +
{$package->name}{$package->version}{strpos($package->version, dev) !== false && $package->hash ? ' #' . substr($package->hash, 0, 4) : ''}{$package->version}{strpos($package->version, dev) !== false && $package->hash ? ' #' . substr($package->hash, 0, 4)}
{/if} diff --git a/src/Tracy/Bar/panels/warnings.panel.latte b/src/Tracy/Bar/panels/warnings.panel.latte index e639c71c0..0d50b83e3 100644 --- a/src/Tracy/Bar/panels/warnings.panel.latte +++ b/src/Tracy/Bar/panels/warnings.panel.latte @@ -6,7 +6,7 @@ {foreach $data as $item => $count} {do [$file, $line, $message] = explode('|', $item, 3)} - {$count ? $count . '×' : ''} + {$count ? $count . '×'}
{$message} in {Tracy\Helpers::editorLink($file, (int) $line)}
{/foreach} diff --git a/src/Tracy/BlueScreen/BlueScreen.php b/src/Tracy/BlueScreen/BlueScreen.php index 1ff43695c..cba650830 100644 --- a/src/Tracy/BlueScreen/BlueScreen.php +++ b/src/Tracy/BlueScreen/BlueScreen.php @@ -7,7 +7,7 @@ namespace Tracy; -use function in_array; +use function count, in_array; use const ARRAY_FILTER_USE_KEY, ENT_IGNORE, PHP_VERSION_ID; @@ -468,7 +468,7 @@ public function getAgentDumper(): \Closure public function formatMessage(\Throwable $exception): string { - $msg = Helpers::encodeString(trim((string) $exception->getMessage()), self::MaxMessageLength, showWhitespaces: false); + $msg = Helpers::encodeString(trim($exception->getMessage()), self::MaxMessageLength, showWhitespaces: false); // highlight 'string' $msg = preg_replace( diff --git a/src/Tracy/BlueScreen/CodeHighlighter.php b/src/Tracy/BlueScreen/CodeHighlighter.php index 8d7003a30..0820b4a4a 100644 --- a/src/Tracy/BlueScreen/CodeHighlighter.php +++ b/src/Tracy/BlueScreen/CodeHighlighter.php @@ -87,7 +87,7 @@ private static function highlightPhpCode(string $code): string $code = str_replace("\r\n", "\n", $code); $code = preg_replace('#(__halt_compiler\s*\(\)\s*;).*#is', '$1', $code); $code = rtrim($code); - $code = preg_replace('#/\*sensitive\{\*/.*?/\*\}\*/#s', Dumper\Describer::HiddenValue, $code); + $code = preg_replace('#/\*sensitive\{\*/.*?/\*}\*/#s', Dumper\Describer::HiddenValue, $code); $last = $out = ''; foreach (\PhpToken::tokenize($code) as $token) { diff --git a/src/Tracy/BlueScreen/assets/agent.latte b/src/Tracy/BlueScreen/assets/agent.latte index 3e2a32bac..214b93919 100644 --- a/src/Tracy/BlueScreen/assets/agent.latte +++ b/src/Tracy/BlueScreen/assets/agent.latte @@ -45,7 +45,7 @@ This is an error page generated by Tracy (https://tracy.nette.org). {foreach Helpers::getExceptionChain($exception) as $i => $ex} {do $title = $blueScreen->getExceptionTitle($ex)} - {do $code = $ex->getCode() ? ' #' . $ex->getCode() : ''} + {do $code = $ex->getCode() ? ' #' . $ex->getCode()} {if $i === 0} # {$title}: {$ex->getMessage()}{$code} {else} diff --git a/src/Tracy/BlueScreen/assets/page.latte b/src/Tracy/BlueScreen/assets/page.latte index 0458a3597..7f80014b2 100644 --- a/src/Tracy/BlueScreen/assets/page.latte +++ b/src/Tracy/BlueScreen/assets/page.latte @@ -6,7 +6,7 @@ {varType string $js} {varType string $source} {do $title = $blueScreen->getExceptionTitle($exception)} -{do $code = $exception->getCode() ? ' #' . $exception->getCode() : ''} +{do $code = $exception->getCode() ? ' #' . $exception->getCode()} {do $chain = Helpers::getExceptionChain($exception)} {* *} @@ -23,7 +23,7 @@ {if count($chain) > 1} {/if} diff --git a/src/Tracy/BlueScreen/assets/section-header.latte b/src/Tracy/BlueScreen/assets/section-header.latte index b46182da4..98938cc96 100644 --- a/src/Tracy/BlueScreen/assets/section-header.latte +++ b/src/Tracy/BlueScreen/assets/section-header.latte @@ -5,7 +5,7 @@ {varType Tracy\BlueScreen $blueScreen} {do $title = $blueScreen->getExceptionTitle($ex)} -{do $code = $ex->getCode() ? ' #' . $ex->getCode() : ''} +{do $code = $ex->getCode() ? ' #' . $ex->getCode()}

{$title}{$code}

diff --git a/src/Tracy/BlueScreen/dist/agent.phtml b/src/Tracy/BlueScreen/dist/agent.phtml index fdaf58fb1..d0ab02451 100644 --- a/src/Tracy/BlueScreen/dist/agent.phtml +++ b/src/Tracy/BlueScreen/dist/agent.phtml @@ -63,7 +63,7 @@ Mapped source: '; echo "\n"; foreach (Helpers::getExceptionChain($exception) as $i => $ex) /* pos 46:1 */ { $title = $blueScreen->getExceptionTitle($ex) /* pos 47:2 */; - $code = $ex->getCode() ? ' #' . $ex->getCode() : '' /* pos 48:2 */; + $code = $ex->getCode() ? ' #' . $ex->getCode() : null /* pos 48:2 */; if ($i === 0) /* pos 49:2 */ { echo '# '; echo Tracy\Helpers::escapeMd($title) /* pos 50:5 */; diff --git a/src/Tracy/BlueScreen/dist/page.phtml b/src/Tracy/BlueScreen/dist/page.phtml index 587232dab..b6e2bb73b 100644 --- a/src/Tracy/BlueScreen/dist/page.phtml +++ b/src/Tracy/BlueScreen/dist/page.phtml @@ -8,7 +8,7 @@ use Tracy\Helpers; /** @var string $js */ /** @var string $source */ $title = $blueScreen->getExceptionTitle($exception) /* pos 8:1 */; -$code = $exception->getCode() ? ' #' . $exception->getCode() : '' /* pos 9:1 */; +$code = $exception->getCode() ? ' #' . $exception->getCode() : null /* pos 9:1 */; $chain = Helpers::getExceptionChain($exception) /* pos 10:1 */; echo '

@@ -39,7 +39,7 @@ if (count($chain) > 1) /* pos 24:2 */ { echo Tracy\Helpers::escapeHtml(get_debug_type($ex)) /* pos 26:12 */; echo ': '; echo Tracy\Helpers::escapeHtml($ex->getMessage()) /* pos 26:35 */; - echo Tracy\Helpers::escapeHtml($ex->getCode() ? ' #' . $ex->getCode() : '') /* pos 26:54 */; + echo Tracy\Helpers::escapeHtml($ex->getCode() ? ' #' . $ex->getCode() : null) /* pos 26:54 */; echo ' '; diff --git a/src/Tracy/BlueScreen/dist/section-header.phtml b/src/Tracy/BlueScreen/dist/section-header.phtml index f6d912bc0..4cdd26a20 100644 --- a/src/Tracy/BlueScreen/dist/section-header.phtml +++ b/src/Tracy/BlueScreen/dist/section-header.phtml @@ -7,7 +7,7 @@ use Tracy\Helpers; /** @var Tracy\BlueScreen $blueScreen */ echo "\n"; $title = $blueScreen->getExceptionTitle($ex) /* pos 7:1 */; -$code = $ex->getCode() ? ' #' . $ex->getCode() : '' /* pos 8:1 */; +$code = $ex->getCode() ? ' #' . $ex->getCode() : null /* pos 8:1 */; echo '
'; diff --git a/src/Tracy/Debugger/Debugger.php b/src/Tracy/Debugger/Debugger.php index 2b581311c..a32ae23dc 100644 --- a/src/Tracy/Debugger/Debugger.php +++ b/src/Tracy/Debugger/Debugger.php @@ -168,7 +168,7 @@ final public function __construct() /** * Enables displaying or logging errors and exceptions. * @param bool|string|string[] $mode use constant Debugger::Production, Development, Detect (autodetection) or IP address(es) whitelist. - * @param string $logDirectory error log directory + * @param ?string $logDirectory error log directory * @param string|string[]|null $email administrator email; enables email sending in production mode */ public static function enable( diff --git a/src/Tracy/Debugger/DeferredContent.php b/src/Tracy/Debugger/DeferredContent.php index 2dfe9f688..8419de797 100644 --- a/src/Tracy/Debugger/DeferredContent.php +++ b/src/Tracy/Debugger/DeferredContent.php @@ -157,7 +157,7 @@ private function buildJsCss(): string public function clean(): void { foreach ($this->sessionStorage->getData() as &$items) { - $items = array_slice((array) $items, -10, null, preserve_keys: true); + $items = array_slice((array) $items, -10, preserve_keys: true); $items = array_filter($items, fn($item) => isset($item['time']) && $item['time'] > time() - 60); } } diff --git a/src/Tracy/Dumper/Describer.php b/src/Tracy/Dumper/Describer.php index d71b67466..dc2ad65f6 100644 --- a/src/Tracy/Dumper/Describer.php +++ b/src/Tracy/Dumper/Describer.php @@ -8,7 +8,7 @@ namespace Tracy\Dumper; use Tracy\Helpers; -use function array_map, array_slice, class_exists, count, explode, file, get_debug_type, get_resource_type, gettype, htmlspecialchars, implode, is_bool, is_file, is_int, is_resource, is_string, is_subclass_of, json_encode, method_exists, preg_match, spl_object_id, str_replace, strlen, strpos, strtolower, trim, uksort; +use function array_map, array_slice, class_exists, count, explode, file, get_debug_type, get_resource_type, gettype, htmlspecialchars, implode, is_bool, is_int, is_resource, is_string, is_subclass_of, json_encode, method_exists, preg_match, spl_object_id, str_replace, strlen, strpos, strtolower, trim, uksort; /** @@ -20,7 +20,7 @@ final class Describer public const HiddenValue = '*****'; // Number.MAX_SAFE_INTEGER - private const JsSafeInteger = 1 << 53 - 1; + private const JsSafeInteger = (1 << 53) - 1; public int $maxDepth = 7; public int $maxLength = 150; diff --git a/src/Tracy/Dumper/Exposer.php b/src/Tracy/Dumper/Exposer.php index 35a0165d7..589e53e5b 100644 --- a/src/Tracy/Dumper/Exposer.php +++ b/src/Tracy/Dumper/Exposer.php @@ -154,7 +154,7 @@ public static function exposeArrayObject(\ArrayObject $obj, Value $value, Descri public static function exposeDOMNode(\DOMNode|Dom\Node $obj, Value $value, Describer $describer): void { - $props = preg_match_all('#^\s*\[([^\]]+)\] =>#m', print_r($obj, return: true), $tmp) ? $tmp[1] : []; + $props = preg_match_all('#^\s*\[([^\]]+)] =>#m', print_r($obj, return: true), $tmp) ? $tmp[1] : []; sort($props); foreach ($props as $p) { $describer->addPropertyTo($value, $p, @$obj->$p, Value::PropertyPublic); // @ some props may be deprecated diff --git a/src/Tracy/Logger/Logger.php b/src/Tracy/Logger/Logger.php index 62415339c..e5f21e399 100644 --- a/src/Tracy/Logger/Logger.php +++ b/src/Tracy/Logger/Logger.php @@ -28,7 +28,7 @@ class Logger implements ILogger /** @var string|int interval for sending email is 2 days */ public $emailSnooze = '2 days'; - /** @var callable(mixed $message, string $email): void handler for sending emails */ + /** @var ?callable(mixed $message, string $email): void handler for sending emails, null disables sending */ public $mailer; /** @var ?BlueScreen */ diff --git a/tests/Tracy/Bar.renderAgent().phpt b/tests/Tracy/Bar.renderAgent().phpt index bad6f6e84..9a434206a 100644 --- a/tests/Tracy/Bar.renderAgent().phpt +++ b/tests/Tracy/Bar.renderAgent().phpt @@ -1,11 +1,9 @@ - Date: Thu, 28 May 2026 01:47:08 +0200 Subject: [PATCH 02/37] phpstan.neon: narrow ignore --- phpstan.neon | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 73d1e6fcd..d312bc555 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -17,17 +17,19 @@ parameters: identifier: closure.unusedUse path: src/Tracy/BlueScreen/dist/agent.phtml - # Tracy doesn't need generic type parameters for Fiber, ArrayObject, DOMNodeList, etc. - - + - # Tracy doesn't need generic type parameters for Fiber, ArrayObject, DOMNodeList, etc. identifier: missingType.generics + message: '#^(Method|Class|Property|PHPDoc tag @var for variable \$\w+ contains|.+ has parameter \$\w+ with|.+ return type with) .+ (generic|class|interface) (class |interface )?\w+\\?\w*(\\?\w*)* (but )?does not specify its types:#' + paths: + - src/Tracy/BlueScreen/BlueScreen.php + - src/Tracy/BlueScreen/dist/content.phtml + - src/Tracy/BlueScreen/dist/section-exception.phtml + - src/Tracy/Dumper/Exposer.php # Runtime validation of callable-string and Closure types - identifier: function.alreadyNarrowedType - paths: - - src/Tracy/Bar/Bar.php - - src/Tracy/BlueScreen/BlueScreen.php - - src/Tracy/Helpers.php + path: src/Tracy/BlueScreen/BlueScreen.php # Tracy uses dynamic properties on exceptions and panels - @@ -55,11 +57,6 @@ parameters: identifier: booleanNot.alwaysFalse path: src/Tracy/Dumper/Describer.php - # Public property can be set to null by user code at runtime - - - identifier: booleanAnd.rightAlwaysTrue - path: src/Tracy/Logger/Logger.php - # Condition inside if-block checking same variable - identifier: ternary.alwaysTrue From 038935d681435c0bdea082fd35af7055e8ce1184 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 18 Jul 2026 01:39:05 +0200 Subject: [PATCH 03/37] phpstan: added stubs for ext-ds 2.0 classes unknown to phpstorm-stubs --- phpstan-stubs.php | 79 +++++++++++++++++++++++++++++++++++++++++++++++ phpstan.neon | 4 +++ 2 files changed, 83 insertions(+) create mode 100644 phpstan-stubs.php diff --git a/phpstan-stubs.php b/phpstan-stubs.php new file mode 100644 index 000000000..7c2db3dd4 --- /dev/null +++ b/phpstan-stubs.php @@ -0,0 +1,79 @@ + Date: Tue, 19 May 2026 22:18:23 +0200 Subject: [PATCH 04/37] TracyExtension: added @property for config --- src/Bridges/Nette/TracyExtension.php | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Bridges/Nette/TracyExtension.php b/src/Bridges/Nette/TracyExtension.php index c6a8f7aa7..e9e6e726a 100644 --- a/src/Bridges/Nette/TracyExtension.php +++ b/src/Bridges/Nette/TracyExtension.php @@ -16,6 +16,29 @@ /** * Tracy extension for Nette DI. + * + * @property object{ + * email: string|list|null, + * fromEmail: string|null, + * emailSnooze: string|null, + * logSeverity: int|string|list|null, + * editor: string|null, + * browser: string|null, + * errorTemplate: string|null, + * strictMode: bool|int|string|list|null, + * showBar: bool|null, + * maxLength: int|null, + * maxDepth: int|null, + * maxItems: int|null, + * keysToHide: array|null, + * dumpTheme: string|null, + * showLocation: bool|null, + * scream: bool|int|string|list|null, + * bar: list, + * blueScreen: list, + * editorMapping: array|null, + * netteMailer: bool, + * } $config */ class TracyExtension extends Nette\DI\CompilerExtension { @@ -78,7 +101,6 @@ public function loadConfiguration(): void public function afterCompile(Nette\PhpGenerator\ClassType $class): void { $config = $this->config; - \assert($config instanceof \stdClass); $initialize = $this->initialization ?? new Nette\PhpGenerator\Closure; $initialize->addBody('if (!Tracy\Debugger::isEnabled()) { return; }'); From 8b436a295e90bf8680a206fff3f221e1909fc297 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Tue, 19 May 2026 15:03:18 +0200 Subject: [PATCH 05/37] tools: added latte-convert template compiler --- .gitattributes | 3 + composer.json | 3 +- tools/latte-convert/.gitignore | 3 + tools/latte-convert/compile.php | 57 ++++ tools/latte-convert/composer.json | 17 + tools/latte-convert/readme.md | 42 +++ tools/latte-convert/src/Engine.php | 68 ++++ tools/latte-convert/src/Escaper.php | 34 ++ tools/latte-convert/src/Extension.php | 156 +++++++++ tools/latte-convert/src/Nodes.php | 298 ++++++++++++++++++ tools/latte-convert/tests/bootstrap.php | 9 + tools/latte-convert/tests/compiler.phpt | 208 ++++++++++++ tools/latte-convert/tests/fixtures.phpt | 23 ++ .../tests/fixtures/kitchen-sink.latte | 72 +++++ .../tests/fixtures/kitchen-sink.phtml | 140 ++++++++ 15 files changed, 1132 insertions(+), 1 deletion(-) create mode 100644 tools/latte-convert/.gitignore create mode 100644 tools/latte-convert/compile.php create mode 100644 tools/latte-convert/composer.json create mode 100644 tools/latte-convert/readme.md create mode 100644 tools/latte-convert/src/Engine.php create mode 100644 tools/latte-convert/src/Escaper.php create mode 100644 tools/latte-convert/src/Extension.php create mode 100644 tools/latte-convert/src/Nodes.php create mode 100644 tools/latte-convert/tests/bootstrap.php create mode 100644 tools/latte-convert/tests/compiler.phpt create mode 100644 tools/latte-convert/tests/fixtures.phpt create mode 100644 tools/latte-convert/tests/fixtures/kitchen-sink.latte create mode 100644 tools/latte-convert/tests/fixtures/kitchen-sink.phtml diff --git a/.gitattributes b/.gitattributes index 433a2de9a..70074d01a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,9 @@ ncs.* export-ignore phpstan*.neon export-ignore src/**/*.latte export-ignore tests/ export-ignore +tools/latte-convert/ export-ignore *.php* diff=php *.sh text eol=lf +tools/latte-convert/tests/fixtures/*.latte text eol=lf +tools/latte-convert/tests/fixtures/*.phtml text eol=lf diff --git a/composer.json b/composer.json index 1c213cbfb..a4f2e7530 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,8 @@ "minimum-stability": "dev", "scripts": { "phpstan": "phpstan analyse", - "tester": "tester tests" + "tester": "tester tests", + "compile-templates": "@php tools/latte-convert/compile.php src/Tracy" }, "extra": { "branch-alias": { diff --git a/tools/latte-convert/.gitignore b/tools/latte-convert/.gitignore new file mode 100644 index 000000000..ce5bb4e8f --- /dev/null +++ b/tools/latte-convert/.gitignore @@ -0,0 +1,3 @@ +/vendor +/composer.lock +/tests/output diff --git a/tools/latte-convert/compile.php b/tools/latte-convert/compile.php new file mode 100644 index 000000000..7551fde96 --- /dev/null +++ b/tools/latte-convert/compile.php @@ -0,0 +1,57 @@ + single file or each *.latte in dir, output to sibling dist/ + * php compile.php single file with explicit output + * + * Files whose basename contains "agent" are compiled in text (markdown) mode. + */ + +require __DIR__ . '/vendor/autoload.php'; + +/** @var list $args */ +$args = $_SERVER['argv'] ?? []; + +if (count($args) < 2) { + fwrite(STDERR, "Usage: php compile.php []\n"); + exit(1); +} + +$engine = new Tracy\Tools\Compiler\Engine; +$path = $args[1]; +if (is_file($path)) { + compileFile($engine, $path, $args[2] ?? deriveOutput($path)); +} elseif (is_dir($path)) { + $iter = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); + foreach (new RegexIterator($iter, '~\.latte$~') as $file) { + compileFile($engine, $file->getPathname(), deriveOutput($file->getPathname())); + } +} else { + fwrite(STDERR, "Not a file or directory: $path\n"); + exit(1); +} + + +function deriveOutput(string $latteFile): string +{ + $distDir = dirname($latteFile, 2) . '/dist'; + if (!is_dir($distDir)) { + mkdir($distDir, recursive: true); + } + return $distDir . '/' . basename($latteFile, '.latte') . '.phtml'; +} + + +function compileFile(Tracy\Tools\Compiler\Engine $engine, string $input, string $output): void +{ + $textMode = str_contains(basename($input), 'agent'); + $engine->setContentType($textMode ? Latte\ContentType::Text : Latte\ContentType::Html); + if (file_put_contents($output, $engine->compile($input)) === false) { + fwrite(STDERR, "Error writing to '$output'\n"); + exit(1); + } + echo "$input -> $output\n"; +} diff --git a/tools/latte-convert/composer.json b/tools/latte-convert/composer.json new file mode 100644 index 000000000..09f11efad --- /dev/null +++ b/tools/latte-convert/composer.json @@ -0,0 +1,17 @@ +{ + "name": "tracy/latte-convert", + "description": "Compiles Tracy's .latte templates into standalone .phtml files with no Latte runtime dependency.", + "license": "BSD-3-Clause", + "type": "library", + "require": { + "php": ">=8.2", + "latte/latte": "^3.1.4" + }, + "require-dev": { + "nette/tester": "^2.6" + }, + "autoload": { + "classmap": ["src"] + }, + "minimum-stability": "dev" +} diff --git a/tools/latte-convert/readme.md b/tools/latte-convert/readme.md new file mode 100644 index 000000000..c0c5f28f4 --- /dev/null +++ b/tools/latte-convert/readme.md @@ -0,0 +1,42 @@ +# Tracy Latte Convert + +Build-time tool that compiles Tracy's `.latte` panel templates into standalone +`.phtml` files with no Latte runtime dependency. The generated `.phtml` files +are committed to the repo and shipped to end users. + +For Tracy maintainers only — end users of `tracy/tracy` never need this. + +## Requirements + +- PHP 8.2+ +- `latte/latte` ^3.1.4 with the following patches (not yet upstreamed): + - `Compiler\Escaper` non-final + - `Compiler\PrintContext::__construct()` accepts optional `?Escaper` + - `Compiler\TagParser::parseType()` accepts `Token::Php_Sr` + - `Compiler\TemplateParser::applyDedent()` uses regex `/^(\t+| +)/` + +## Usage + +```bash +# one-time setup +cd tools/latte-convert && composer install + +# compile all Tracy templates +composer compile-templates + +# compile a single .latte file (output auto-derived to sibling dist/) +php tools/latte-convert/compile.php src/Tracy/Bar/assets/bar.latte + +# compile a single file with explicit output +php tools/latte-convert/compile.php in.latte out.phtml + +# compile every .latte in a directory tree (recursive) +php tools/latte-convert/compile.php src/Tracy + +# tests +cd tools/latte-convert && vendor/bin/tester tests +``` + +**Output convention:** each `/.latte` compiles to `/../dist/.phtml` +(sibling `dist/` of the input directory). Templates whose basename contains `agent` +are compiled in text (markdown) mode. diff --git a/tools/latte-convert/src/Engine.php b/tools/latte-convert/src/Engine.php new file mode 100644 index 000000000..7731a2c8f --- /dev/null +++ b/tools/latte-convert/src/Engine.php @@ -0,0 +1,68 @@ +setFeature(Latte\Feature::Dedent, true); + } + + + protected function addDefaultExtensions(): void + { + $this->addExtension(new Extension); + } + + + /** + * Generates standalone PHP code from AST (no class wrapper, no Latte runtime). + */ + public function generate(TemplateNode $node, string $name): string + { + $escaper = new Escaper($node->contentType); + + $features = []; + foreach (Latte\Feature::cases() as $feature) { + if ($this->hasFeature($feature)) { + $features[$feature->name] = true; + } + } + + $context = new PrintContext($node->contentType, $features, $escaper); + + // Compile head (varType, use declarations) + $head = $node->head->print($context); + + // Compile main content + $main = $node->main->print($context); + + // Assemble standalone PHP + $code = "getContentType()) { + Latte\ContentType::Html => match ($this->getState()) { + self::HtmlText, self::HtmlAttribute, self::HtmlTag, self::HtmlBogusTag, self::HtmlComment + => 'Tracy\Helpers::escapeHtml(' . $str . ')', + self::HtmlRawText + => 'Tracy\Helpers::jsonEncode(' . $str . ', true)', + default => 'Tracy\Helpers::escapeHtml(' . $str . ')', + }, + Latte\ContentType::Text => 'Tracy\Helpers::escapeMd(' . $str . ')', + default => $str, + }; + } + + + public function escapeMandatory(string $str, ?Latte\Compiler\Position $position = null): string + { + return $str; + } +} diff --git a/tools/latte-convert/src/Extension.php b/tools/latte-convert/src/Extension.php new file mode 100644 index 000000000..6dbe20216 --- /dev/null +++ b/tools/latte-convert/src/Extension.php @@ -0,0 +1,156 @@ +formatMessage\(~'; + + + public function getTags(): array + { + return [ + // Standard Latte nodes (standalone-compatible, no $this references) + 'if' => Nodes\IfNode::create(...), + 'ifset' => Nodes\IfNode::create(...), + 'foreach' => Nodes\ForeachNode::create(...), + 'for' => Nodes\ForNode::create(...), + 'while' => Nodes\WhileNode::create(...), + 'switch' => Nodes\SwitchNode::create(...), + 'do' => Nodes\DoNode::create(...), + 'var' => Nodes\VarNode::create(...), + 'default' => Nodes\VarNode::create(...), + 'exitIf' => JumpNode::create(...), + 'continueIf' => JumpNode::create(...), + 'breakIf' => JumpNode::create(...), + '=' => PrintNode::create(...), + 'l' => fn(Latte\Compiler\Tag $tag) => new Latte\Compiler\Nodes\TextNode('{', $tag->position), + 'r' => fn(Latte\Compiler\Tag $tag) => new Latte\Compiler\Nodes\TextNode('}', $tag->position), + + // Custom Tracy nodes (generate standalone PHP) + 'varType' => VarTypeNode::create(...), + 'use' => UseNode::create(...), + 'define' => DefineNode::create(...), + 'include' => IncludeNode::create(...), + 'try' => TryNode::create(...), + ]; + } + + + public function getPasses(): array + { + return [ + 'tracyAttributes' => $this->attributePass(...), + 'tracyPrintNode' => $this->printNodePass(...), + ]; + } + + + /** + * Replaces ExpressionAttributeNode with Tracy-compatible inline code. + * Runs at compile-time, classifies attributes and generates standalone PHP. + */ + private function attributePass(TemplateNode $node): void + { + $noEscapePattern = self::NoEscapePattern; + + (new NodeTraverser)->traverse($node, function (Node $node) use ($noEscapePattern): ?Node { + if (!$node instanceof ExpressionAttributeNode) { + return null; + } + + $attr = $node; + $type = HtmlHelpers::classifyAttributeType($attr->name); + $isArray = $attr->value instanceof ArrayNode; + $hasJsonFilter = (bool) $attr->modifier->removeFilter('json'); + + // list attributes with non-array values fall back to regular attribute handling + if ($type === 'list' && !$isArray) { + $type = ''; + } + + // Check if expression contains known HTML-generating functions + $valueCode = $attr->value->print(new PrintContext); + $shouldEscape = !$hasJsonFilter && !preg_match($noEscapePattern, $valueCode); + + return new AuxiliaryNode( + function (PrintContext $context) use ($attr, $type, $shouldEscape, $hasJsonFilter): string { + $value = $attr->value->print($context); + if ($hasJsonFilter) { + $value = 'Tracy\Helpers::jsonEncode(' . $value . ')'; + } + $namePart = $attr->indentation . $attr->name; + $pos = $attr->value->position; + $line = $pos ? "/* pos $pos->line" . ($pos->column ? ":$pos->column" : '') . ' */' : ''; + + if (!$shouldEscape) { + $q = $hasJsonFilter ? "'" : '"'; + return 'echo ($ʟ_tmp = (' . $value . ')) === null ? \'\' : ' + . var_export($namePart . '=' . $q, true) . ' . $ʟ_tmp . ' . var_export($q, true) . " $line;\n"; + } + + return match ($type) { + 'bool' => "echo ($value) ? " . var_export($namePart, true) . " : '' $line;\n", + 'list' => 'echo ($ʟ_tmp = array_filter(' . $value . ')) ? ' + . var_export($namePart . '="', true) . " . Tracy\\Helpers::escapeHtml(implode(' ', \$ʟ_tmp)) . '\"' : '' $line;\n", + default => 'echo ($ʟ_tmp = (' . $value . ')) === null ? \'\' : ' + . var_export($namePart . '="', true) . " . Tracy\\Helpers::escapeHtml(\$ʟ_tmp) . '\"' $line;\n", + }; + }, + [$attr->value], + ); + }); + } + + + /** + * Handles |json filter and auto-disables escaping for known HTML-generating functions. + */ + private function printNodePass(TemplateNode $node): void + { + (new NodeTraverser)->traverse($node, function (Node $node): ?Node { + if (!$node instanceof PrintNode) { + return null; + } + + // |json filter → wrap in Tracy\Helpers::jsonEncode() + if ($node->modifier->removeFilter('json')) { + $node->modifier->escape = false; + $origExpr = $node->expression; + $node->expression = new Latte\Compiler\Nodes\Php\Expression\AuxiliaryNode( + fn(PrintContext $context) => 'Tracy\Helpers::jsonEncode(' . $origExpr->print($context) . ')', + [$origExpr], + ); + return $node; + } + + // Disable escaping for functions that return pre-escaped HTML + if ($node->modifier->escape) { + $code = $node->expression->print(new PrintContext); + if (preg_match(self::NoEscapePattern, $code)) { + $node->modifier->escape = false; + } + } + + return null; + }); + } +} diff --git a/tools/latte-convert/src/Nodes.php b/tools/latte-convert/src/Nodes.php new file mode 100644 index 000000000..20c507cf4 --- /dev/null +++ b/tools/latte-convert/src/Nodes.php @@ -0,0 +1,298 @@ +expectArguments(); + $node = new static; + $typeNode = $tag->parser->parseType(); + $node->typeStr = $typeNode ? $typeNode->type : ''; + $token = $tag->parser->stream->consume(Token::Php_Variable); + $node->variable = $token->text; + return $node; + } + + + public function print(PrintContext $context): string + { + return "/** @var $this->typeStr $this->variable */\n"; + } + + + public function &getIterator(): \Generator + { + false && yield; + } +} + + +/** + * {use Tracy\Helpers} + * Generates PHP use statement. + */ +class UseNode extends StatementNode +{ + public string $namespace; + + + public static function create(Tag $tag): static + { + $tag->expectArguments(); + $node = new static; + // Consume all remaining tokens as the namespace path + $parts = []; + while (!$tag->parser->stream->is(Token::End)) { + $parts[] = $tag->parser->stream->consume()->text; + } + $node->namespace = implode('', $parts); + return $node; + } + + + public function print(PrintContext $context): string + { + return 'use ' . $this->namespace . ";\n"; + } + + + public function &getIterator(): \Generator + { + false && yield; + } +} + + +/** + * {define name $param1, $param2} ... {/define} + * Generates $_blocks closure (standard Latte DefineNode generates class methods). + */ +class DefineNode extends StatementNode +{ + public string $name; + public string $params; + public AreaNode $content; + + + /** @return \Generator, array{AreaNode, ?Tag}, static> */ + public static function create(Tag $tag, TemplateParser $parser): \Generator + { + $tag->expectArguments(); + $node = $tag->node = new static; + + // Parse block name + $tag->parser->stream->tryConsume('#'); + $nameExpr = $tag->parser->parseUnquotedStringOrExpression(); + if ($nameExpr instanceof Scalar\StringNode) { + $node->name = $nameExpr->value; + } else { + throw new CompileException('Block name must be a static string.', $tag->position); + } + + // Parse parameters as raw string + $tag->parser->stream->tryConsume(','); + $params = []; + while (!$tag->parser->stream->is(Token::End)) { + $params[] = $tag->parser->stream->consume()->text; + } + $node->params = implode('', $params); + + [$node->content, $endTag] = yield; + if ($endTag) { + $endTag->parser->stream->tryConsume($node->name); + } + + return $node; + } + + + public function print(PrintContext $context): string + { + $content = $this->content->print($context); + return '$_blocks[' . var_export($this->name, true) . '] = function (' . $this->params . ") use (&\$_blocks) {\n" + . $content + . "};\n"; + } + + + public function &getIterator(): \Generator + { + yield $this->content; + } +} + + +/** + * {include name $args} or {include 'file.phtml'} + * Block include: $_blocks['name']($args) + * File include: require __DIR__ . '/file.phtml' + */ +class IncludeNode extends StatementNode +{ + public bool $isFile = false; + public string $name; + public string $args; + + + public static function create(Tag $tag, TemplateParser $parser): static + { + $tag->outputMode = $tag::OutputRemoveIndentation; + $tag->expectArguments(); + + $node = new static; + + // Try to detect block vs file keyword + $tag->parser->tryConsumeTokenBeforeUnquotedString('block', 'file'); + $tag->parser->stream->tryConsume('#'); + + $nameExpr = $tag->parser->parseUnquotedStringOrExpression(); + if ($nameExpr instanceof Scalar\StringNode) { + $node->name = $nameExpr->value; + $node->isFile = str_contains($node->name, '.'); + } else { + throw new CompileException('Include name must be a static string.', $tag->position); + } + + // Parse remaining arguments as raw string + $tag->parser->stream->tryConsume(','); + $args = []; + while (!$tag->parser->stream->is(Token::End)) { + $args[] = $tag->parser->stream->consume()->text; + } + $node->args = implode('', $args); + + return $node; + } + + + public function print(PrintContext $context): string + { + if ($this->isFile) { + return 'require __DIR__ . ' . var_export('/' . $this->name, true) . ";\n"; + } + + return '$_blocks[' . var_export($this->name, true) . "]($this->args);\n"; + } + + + public function &getIterator(): \Generator + { + false && yield; + } +} + + +/** + * {try} ... {rollback} ... {/try} + * Generates simple try/catch (standard Latte TryNode uses output buffering). + */ +class TryNode extends StatementNode +{ + public AreaNode $try; + public ?AreaNode $catch = null; + + + /** @return \Generator, array{AreaNode, ?Tag}, static> */ + public static function create(Tag $tag): \Generator + { + $node = $tag->node = new static; + [$node->try, $nextTag] = yield ['rollback', 'else']; + if ($nextTag?->name === 'rollback' || $nextTag?->name === 'else') { + [$node->catch] = yield; + } + + return $node; + } + + + public function print(PrintContext $context): string + { + $code = "try {\n" . $this->try->print($context) . "}\n"; + if ($this->catch) { + $code .= "catch (\\Throwable) {\n" . $this->catch->print($context) . "}\n"; + } else { + $code .= "catch (\\Throwable) {}\n"; + } + + return $code; + } + + + public function &getIterator(): \Generator + { + yield $this->try; + if ($this->catch) { + yield $this->catch; + } + } +} + + +/** + * {exitIf $cond}, {continueIf $cond}, {breakIf $cond} + * Simplified JumpNode without parent-tag context checks. + */ +class JumpNode extends StatementNode +{ + public string $type; + public ExpressionNode $condition; + + + public static function create(Tag $tag): static + { + $tag->expectArguments(); + $tag->outputMode = $tag::OutputRemoveIndentation; + $node = new static; + $node->type = $tag->name; + $node->condition = $tag->parser->parseExpression(); + return $node; + } + + + public function print(PrintContext $context): string + { + return $context->format( + "if (%node) %line %raw\n", + $this->condition, + $this->position, + match ($this->type) { + 'breakIf' => 'break;', + 'continueIf' => 'continue;', + 'exitIf' => 'return;', + default => 'return;', + }, + ); + } + + + public function &getIterator(): \Generator + { + yield $this->condition; + } +} diff --git a/tools/latte-convert/tests/bootstrap.php b/tools/latte-convert/tests/bootstrap.php new file mode 100644 index 000000000..e45ea119d --- /dev/null +++ b/tools/latte-convert/tests/bootstrap.php @@ -0,0 +1,9 @@ +setLoader(new Latte\Loaders\StringLoader([$name => $template])); + $engine->setContentType(Latte\ContentType::Html); + return $engine->compile($name); +} + + +function compileText(string $template): string +{ + global $engine; + static $n = 0; + $name = 'text' . $n++; + $engine->setLoader(new Latte\Loaders\StringLoader([$name => $template])); + $engine->setContentType(Latte\ContentType::Text); + return $engine->compile($name); +} + + +// Escaping + +test('HTML text is escaped', function () { + $r = compile('{$foo}'); + Assert::contains('Tracy\Helpers::escapeHtml($foo)', $r); +}); + +test('|noescape disables escaping', function () { + $r = compile('{$foo|noescape}'); + Assert::notContains('escapeHtml', $r); + Assert::contains('$foo', $r); +}); + +test('script context uses jsonEncode', function () { + $r = compile(''); + Assert::contains('Tracy\Helpers::jsonEncode($data, true)', $r); +}); + +test('text mode disables escaping', function () { + $r = compileText('{$foo}'); + Assert::notContains('escapeHtml', $r); + Assert::notContains('jsonEncode', $r); +}); + +test('regular expression is escaped', function () { + $r = compile('{$name}'); + Assert::contains('Tracy\Helpers::escapeHtml($name)', $r); +}); + + +// noEscape for known functions + +test('editorLink not escaped', function () { + $r = compile('{Helpers::editorLink($f, $l)}'); + Assert::notContains('escapeHtml', $r); +}); + +test('highlightFile not escaped', function () { + $r = compile('{BlueScreen::highlightFile($f, $l)}'); + Assert::notContains('escapeHtml', $r); +}); + +test('$dump() not escaped', function () { + $r = compile('{$dump($v)}'); + Assert::notContains('escapeHtml', $r); +}); + +test('Dumper::toHtml not escaped', function () { + $r = compile('{Dumper::toHtml($v)}'); + Assert::notContains('escapeHtml', $r); +}); + +test('formatMessage not escaped', function () { + $r = compile('{$blueScreen->formatMessage($ex)}'); + Assert::notContains('escapeHtml', $r); +}); + + +// |json filter + +test('|json in text context', function () { + $r = compile('{=[1,2,3]|json}'); + Assert::contains('Tracy\Helpers::jsonEncode([1, 2, 3])', $r); + Assert::notContains('escapeHtml', $r); +}); + +test('|json in attribute uses single quotes', function () { + $r = compile(''); + Assert::contains('jsonEncode', $r); + Assert::contains("foo=\\'", $r); +}); + +test('|json on data attribute', function () { + $r = compile('
');
+	Assert::contains('Tracy\Helpers::jsonEncode($snapshot)', $r);
+	Assert::contains("data-snapshot=\\'", $r);
+	Assert::notContains('escapeHtml', $r);
+});
+
+
+// Attribute expressions
+
+test('regular attr={$var}', function () {
+	$r = compile('
'); + Assert::contains('escapeHtml', $r); + Assert::contains('attr="', $r); +}); + +test('boolean attr (hidden)', function () { + $r = compile(''); + Assert::contains("' hidden'", $r); +}); + +test('class={[...]} list attribute', function () { + $r = compile('
'); + Assert::contains('array_filter', $r); + Assert::contains('implode', $r); +}); + +test('noEscape in attribute (formatSnapshotAttribute)', function () { + $r = compile(''); + Assert::notContains('escapeHtml', $r); +}); + + +// Tags + +test('{varType}', function () { + $r = compile('{varType string $name}'); + Assert::contains('/** @var string $name */', $r); +}); + +test('{use}', function () { + $r = compile('{use Tracy\Helpers}'); + Assert::contains('use Tracy\Helpers;', $r); +}); + +test('{var}', function () { + $r = compile('{var $x = 42}'); + Assert::contains('$x = 42', $r); +}); + +test('{do}', function () { + $r = compile('{do $x = 1}'); + Assert::contains('$x = 1', $r); +}); + +test('{if}/{else}/{/if}', function () { + $r = compile('{if $cond}A{else}B{/if}'); + Assert::contains('if ($cond)', $r); + Assert::contains('else', $r); +}); + +test('{foreach}', function () { + $r = compile('{foreach $items as $item}{$item}{/foreach}'); + Assert::contains('foreach ($items as $item)', $r); +}); + +test('{exitIf}', function () { + $r = compile('{exitIf $done}'); + Assert::contains('if ($done)', $r); + Assert::contains('return;', $r); +}); + +test('{continueIf}', function () { + $r = compile('{foreach $a as $b}{continueIf $b}{/foreach}'); + Assert::contains('continue;', $r); +}); + +test('{define}/{include} block', function () { + $r = compile('{define foo}hello{/define}{include foo}'); + Assert::contains("\$_blocks['foo'] = function", $r); + Assert::contains("\$_blocks['foo']()", $r); +}); + +test('{include file}', function () { + $r = compile("{include 'section.phtml'}"); + Assert::contains("require __DIR__ . '/section.phtml'", $r); +}); + +test('{try}/{rollback}', function () { + $r = compile('{try}{do $x = dangerous()}{rollback}{do $x = fallback()}{/try}'); + Assert::contains('try {', $r); + Assert::contains('catch (\Throwable)', $r); +}); + + +// Output format + +test('starts with setContentType($textMode ? Latte\ContentType::Text : Latte\ContentType::Html); + Assert::same(file_get_contents($phtmlFile), $engine->compile($latteFile)); + }); +} diff --git a/tools/latte-convert/tests/fixtures/kitchen-sink.latte b/tools/latte-convert/tests/fixtures/kitchen-sink.latte new file mode 100644 index 000000000..95d39cd8e --- /dev/null +++ b/tools/latte-convert/tests/fixtures/kitchen-sink.latte @@ -0,0 +1,72 @@ +{varType string $file} +{varType int $line} +{varType int $expanded} +{use Tracy\Helpers} + + +

Queries: {$count}{$totalTime ? foo(' time ms ', " time ms ", '', "") : Anjb-d}, {$name . (string) $foo} {=\JSON_UNESCAPED_SLASHES}

+ +
+ {define foo} + hello + {exitIf true} + {/define} + + {define bar $arg, $arg} + hello + {/define} + + {include foo} + {include for $var1, var} + {include 'section-exception.phtml'} + + {foreach $events as [$connection, $query, $trace, $time, $rows, $error, $command, $explain]} + + + {if $error} + ERROR + {elseif $time !== null}{sprintf('%0.3f', $time * 1000)} + {/if} + + {if $explain}
explain{/if} + {if $trace}
trace{/if} + + + + {Nette\Database\Helpers::dumpSql($query, $connection)|noescape} + + {if $trace} + {substr_replace(Helpers::editorLink($trace[0][file], $trace[0][line]), ' class="nette-DbConnectionPanel-source"', 2, 0)|noescape} + + {foreach $trace as $row} + + + + + {/foreach} +
{isset($row[file]) ? Tracy\Helpers::editorLink($row[file], $row[line])}{$row[class] ?? ''}{$row[type] ?? ''}{$row[function]}()
+ {/if} + + + {$rows} + + {/foreach} + + {if count($events) < $count}

...and more

{/if} +
+ +
+ + +link +
+ +{try} + {do $x = dangerous()} +{rollback} + {do $x = fallback()} +{/try} diff --git a/tools/latte-convert/tests/fixtures/kitchen-sink.phtml b/tools/latte-convert/tests/fixtures/kitchen-sink.phtml new file mode 100644 index 000000000..e0f2760c2 --- /dev/null +++ b/tools/latte-convert/tests/fixtures/kitchen-sink.phtml @@ -0,0 +1,140 @@ + + #tracy-debug td.nette-DbConnectionPanel-sql { background: white !important; overflow-x: auto; max-width: 0; } + #tracy-debug .nette-DbConnectionPanel-source { color: #BBB !important } + #tracy-debug .nette-DbConnectionPanel-explain td { white-space: pre } + + +

Queries: '; +echo Tracy\Helpers::escapeHtml($count) /* pos 11:14 */; +echo Tracy\Helpers::escapeHtml($totalTime ? foo(' time ms ', ' time ms ', '', '') : 'Anjb-d') /* pos 11:22 */; +echo ', '; +echo Tracy\Helpers::escapeHtml($name . (string) $foo) /* pos 11:85 */; +echo ' '; +echo Tracy\Helpers::escapeHtml(\JSON_UNESCAPED_SLASHES) /* pos 11:109 */; +echo '

+ +
+'; +$_blocks['foo'] = function () use (&$_blocks) { + echo ' hello +'; + if (true) /* pos 16:3 */ return; +}; +echo "\n"; +$_blocks['bar'] = function ($arg,$arg) use (&$_blocks) { + echo ' hello +'; +}; +echo "\n"; +$_blocks['foo'](); +$_blocks['for']($var1,var); +require __DIR__ . '/section-exception.phtml'; +echo "\n"; +foreach ($events as [$connection, $query, $trace, $time, $rows, $error, $command, $explain]) /* pos 27:2 */ { + echo ' + +'; + if ($error) /* pos 30:5 */ { + echo ' ERROR + '; + } elseif ($time !== null) /* pos 32:6 */ { + echo Tracy\Helpers::escapeHtml(sprintf('%0.3f', $time * 1000)) /* pos 32:29 */; + echo "\n"; + } + + echo "\n"; + if ($explain) /* pos 35:5 */ { + echo '
explain'; + } + echo "\n"; + if ($trace) /* pos 36:5 */ { + echo '
trace'; + } + echo ' + + + + '; + echo Nette\Database\Helpers::dumpSql($query, $connection) /* pos 40:5 */; + echo ' + +'; + if ($trace) /* pos 42:5 */ { + echo ' '; + echo substr_replace(Helpers::editorLink($trace[0]['file'], $trace[0]['line']), ' class="nette-DbConnectionPanel-source"', 2, 0) /* pos 43:6 */; + echo ' + +'; + foreach ($trace as $row) /* pos 45:7 */ { + echo ' + + + +'; + + } + + echo '
'; + echo isset($row['file']) ? Tracy\Helpers::editorLink($row['file'], $row['line']) : null /* pos 47:13 */; + echo ''; + echo Tracy\Helpers::escapeHtml($row['class'] ?? '') /* pos 48:13 */; + echo Tracy\Helpers::escapeHtml($row['type'] ?? '') /* pos 48:32 */; + echo Tracy\Helpers::escapeHtml($row['function']) /* pos 48:50 */; + echo '()
+'; + } + echo ' + + '; + echo Tracy\Helpers::escapeHtml($rows) /* pos 55:8 */; + echo ' + +'; + +} + +echo "\n"; +if (count($events) < $count) /* pos 59:2 */ { + echo '

...and more

'; +} +echo ' +
+ +
+ + +link +
+ +'; +try { + $x = dangerous() /* pos 69:2 */; +} catch (\Throwable) { + $x = fallback() /* pos 71:2 */; +} From da1ecbf538c588cc09ff209fa3282a546a56ef94 Mon Sep 17 00:00:00 2001 From: Chris Yates Date: Sun, 12 Apr 2026 22:06:43 +0100 Subject: [PATCH 06/37] readme: added Yii3 Integration (#599) --- readme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/readme.md b/readme.md index 933419c07..2298b2d43 100644 --- a/readme.md +++ b/readme.md @@ -457,5 +457,6 @@ This is a list of unofficial integrations to other frameworks and CMS: - Symfony framework: [kutny/tracy-bundle](https://github.com/kutny/tracy-bundle), [VasekPurchart/Tracy-Blue-Screen-Bundle](https://github.com/VasekPurchart/Tracy-Blue-Screen-Bundle) - [Wordpress](https://github.com/ktstudio/WP-Tracy) - [Joomla! CMS](https://n3t.bitbucket.io/extension/n3t-debug/) +- [Yii3 framework](https://github.com/beastbytes/yii-tracy) ... feel free to be famous, create an integration for your favourite platform! From b6973d148f30b7ad2a8b1834fd04d150faf5485a Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 20 Feb 2026 04:05:00 +0100 Subject: [PATCH 07/37] added AGENTS.md & DOCS --- .gitattributes | 2 + AGENTS.md | 82 +++++++++++++++++++++++ docs/internals/bar.md | 45 +++++++++++++ docs/internals/bluescreen.md | 52 +++++++++++++++ docs/internals/deferred-content.md | 57 ++++++++++++++++ docs/internals/dumper.md | 70 ++++++++++++++++++++ docs/internals/error-handling.md | 68 +++++++++++++++++++ docs/internals/helpers.md | 24 +++++++ docs/internals/js-contract.md | 102 +++++++++++++++++++++++++++++ docs/internals/logger.md | 26 ++++++++ docs/internals/readme.md | 21 ++++++ 11 files changed, 549 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/internals/bar.md create mode 100644 docs/internals/bluescreen.md create mode 100644 docs/internals/deferred-content.md create mode 100644 docs/internals/dumper.md create mode 100644 docs/internals/error-handling.md create mode 100644 docs/internals/helpers.md create mode 100644 docs/internals/js-contract.md create mode 100644 docs/internals/logger.md create mode 100644 docs/internals/readme.md diff --git a/.gitattributes b/.gitattributes index 70074d01a..9cdfca08c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,11 @@ .gitattributes export-ignore .github/ export-ignore .gitignore export-ignore +AGENTS.md export-ignore ncs.* export-ignore phpstan*.neon export-ignore src/**/*.latte export-ignore +docs/ export-ignore tests/ export-ignore tools/latte-convert/ export-ignore diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..3b1fc84c3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,82 @@ +# To My Agents! + +It is my fervent wish that this file guide every AI coding agent working with code in this repository. + +## Documentation + +Any distilled, agent-facing documentation for this package - how it works +internally and the rationale behind key design decisions - lives in `docs/`. +Consult it before non-trivial changes; it is the source of truth from which the +public manual is distilled. + +Tracy is several independent mechanisms that share little context (error handling, +deferred content, the dumper, BlueScreen, the Bar, the logger). Read the relevant +`docs/internals/` seam before editing one - especially `deferred-content.md`, the +most counterintuitive part. + +## Project Overview + +Tracy is a debugging and error-visualization library for PHP: BlueScreen error +pages, the floating debug Bar with an extensible panel system, an advanced variable +Dumper, and a production error Logger. It auto-detects development vs production and +emits **markdown to the JS console for automated browsers** (`navigator.webdriver`). + +- **PHP Version**: 8.2 - 8.5 +- **Package**: `tracy/tracy` (currently v2.12) + +## Essential Commands + +```bash +# Run all tests - HTML tests only run under php-cgi, so pass -p php-cgi +vendor/bin/tester tests -p php-cgi -s +vendor/bin/tester tests/Dumper/ -s + +# Static analysis (PHPStan level 8) +composer phpstan + +# JavaScript assets +npm run lint # and lint:fix + +# Rebuild templates: .latte/*.agent.latte assets -> .phtml in dist/ +composer compile-templates +``` + +## Conventions + +- Every PHP file starts with `declare(strict_types=1);`; **tabs**; return type and + opening brace on separate lines; Nette Coding Standard (`ncs.php`). JS is linted + with `@nette/eslint-plugin`. +- Tests are Nette Tester `.phpt` using `test()` and `getTempDir()`. **CI runs both + `php` and `php-cgi`**; UI-rendering tests need `php-cgi`. +- Templates are `.latte` (HTML-escaping) / `*.agent.latte` (text/markdown, no + escaping) compiled to committed `.phtml` in `dist/` via + `composer compile-templates` - edit the source, rebuild. + +## Working in this repo + +- **`enable()` does NOT start an output buffer.** It records `$obLevel` and strips + buffers *above* it (`removeOutputBuffers`). Handler registration order is + shutdown -> exception -> error; strategy/dispatch run before registration; + `$reserved` is the double-render guard. See `docs/internals/error-handling.md`. +- **`DeferredContent` is the counterintuitive core.** The Bar/BlueScreen survive a + redirect and ride AJAX responses **through the session**: content is written by + reference, then the browser fetches `?_tracy_bar=content.` and consumes it + **once**. `FileSession` holds `LOCK_EX` for the whole request and writes only in + `__destruct` (a crash loses it). +- **The Dumper is two-phase: describe -> render, over a snapshot.** Cycles are + broken at describe time (`TypeRef` depth guard) and labelled at render time. + Bar/BlueScreen share one live snapshot per page. +- **BlueScreen panels are called repeatedly** (once per exception in the chain, + plus once with `null`). +- **The Logger dedups by an `xxh128` hash** (same exception -> same file, no + overwrite) and throttles email via an email-sent mtime (`emailSnooze`). +- **CSS isolation uses a `` host element plus an aggressive + `reset.css`** (no Shadow DOM) - the Bar and BlueScreen (``) + both live inside `` wrappers in the regular DOM. +- **The PHP <-> JS boundary is coupled purely by strings** (function names, + element ids, attribute and storage keys) with no static checking - before + renaming anything on either side, see `docs/internals/js-contract.md`. +- Agent detection is `Helpers::isAgent()` reading the `tracy-webdriver` cookie set by + `bar.js`; that path feeds the console-markdown output. +- User-facing how-to (configuration, custom panels/loggers/scrubbers, CSP, editor + integration, session/nginx recipes) is manual material and lives in the web docs. diff --git a/docs/internals/bar.md b/docs/internals/bar.md new file mode 100644 index 000000000..d054e0019 --- /dev/null +++ b/docs/internals/bar.md @@ -0,0 +1,45 @@ +# Bar + +The debug toolbar is a collection of `IBarPanel`s rendered **after the response +body**, through `DeferredContent` (see deferred-content.md). + +## Panels + +`addPanel(IBarPanel $panel, ?string $id = null)` stores the panel under an id +auto-derived from its class (suffixed `-2`, `-3`… on collision). Note the +**panel**, not the Bar, carries `getTab()`/`getPanel()` — `Bar` exposes only +`getPanel($id)`. `renderPanels()` calls each panel's `getTab()` and, only if the tab +is non-empty, `getPanel()`; it wraps rendering in a temporary error handler +(errors become `ErrorException`s) and unwinds output buffers on a throw — a +throwing panel is caught and replaced with an "Error in ``" panel. +`renderAgent()` produces the markdown line `Tracy Bar | | ` plus each +panel's *optional* `getAgentInfo()` (probed via `method_exists`; it is not part of +the `IBarPanel` contract). + +The built-in panels are `DefaultBarPanel`s backed by `.phtml` templates: `info` and +`warnings` (registered when the Bar is created; `warnings` is filled by +`errorHandler`), and `dumps` (registered lazily on the first `barDump()`). +**The ids `Tracy:info` and `Tracy:warnings` are load-bearing strings**: +`DevelopmentStrategy` fetches them by exact id and writes their public/dynamic +properties (`cpuUsage`, `$data`) from outside — `getPanel()` returns `null` for an +unknown id, so renaming a registration is a runtime fatal, not a graceful +degradation. `DefaultBarPanel` needs `#[\AllowDynamicProperties]` for the same +reason. + +## Rendering is deferred and mode-dependent + +`render(DeferredContent $defer)` branches: + +- **AJAX/deferred** → `addSetup('Tracy.Debug.loadAjax', renderPartial('ajax'))`. +- **Redirect** → push the partial onto the session `redirect` queue. +- **Normal HTML** → render the `main` partial, **drain the redirect queue** (reverse + order, then set to `null` — the queue is a by-reference session item, so draining + is a persistent session mutation) so Bars from prior redirects appear now, then + either `addSetup('Tracy.Debug.init', …)` if the loader already ran, or `require` + `loader.phtml` directly. If a `Content-Length` header was already sent (the + injected markup would corrupt it), it only logs a `LogicException` — rendering + proceeds unchanged. + +`renderLoader()` requires an available session (else "Start session before Tracy is +enabled.") and emits the loader early so the toolbar can appear even when the rest +of the page is slow. diff --git a/docs/internals/bluescreen.md b/docs/internals/bluescreen.md new file mode 100644 index 000000000..421e8829b --- /dev/null +++ b/docs/internals/bluescreen.md @@ -0,0 +1,52 @@ +# BlueScreen + +`render()` builds the HTML error page from `page.phtml`; `renderToAjax` defers it +(`addSetup('Tracy.BlueScreen.loadAjax', …)`), `renderToFile` writes it with +`fopen(…, 'x')` (so an existing file is never overwritten) plus a `.md` companion, +and `renderAgent` produces the markdown variant. `renderTemplate` is the shared core +that assembles headers, CSS/JS assets, the dumpers, and a **live shared snapshot** +(`$this->snapshot = []; $snapshot = &$this->snapshot[0]`) before `require`-ing the +template. + +## Panels are callbacks, called repeatedly + +`addPanel(callable)` stores a `Closure(?Throwable): ?array{tab, panel}`. It is +invoked **multiple times with different arguments** during a render: + +- once **per exception in the chain** (`section-exception.phtml` is re-`require`d + for every `getPrevious()` link, each time calling `renderPanels($ex)`), +- plus once **with `null`** (below the call stack, `content.phtml`). + +So a chain of N exceptions means N+1 invocations. A panel wanting to appear at the +very bottom returns `bottom: true`, which defers it to `$bottomPanels`. A panel +callback must tolerate both a `Throwable` and `null`, and is responsible for +rendering the right thing in each pass. Empty tab/panel results are skipped; a +throwing panel becomes an "Error in panel" block. (Separate from panels: +`addAction`, `addFileGenerator`, `addFiber`.) + +## Stack, highlighting, and the two dumpers + +`prepareStack` strips Tracy's own frames (`DevelopmentStrategy`/`ProductionStrategy`, +`Debugger::shutdownHandler`/`errorHandler`) from the trace and returns +`[$stack, $expanded]` — the index of the single frame to auto-expand, computed from +`Debugger::$transparentPaths` (the deprecated `$collapsePaths` is still merged in, +so it remains functionally live); the `tracy-collapsed` class itself is applied in +the template by comparing against that index. `CodeHighlighter` tokenizes with +`\PhpToken`, maps tokens to CSS classes, shows ~15 lines around the error, +highlights the line and column, and replaces `/*sensitive{*/…/*}*/` regions with +`*****` (`Describer::HiddenValue`) before highlighting (PHP path only, not the +plain-text one). + +Two dumpers exist: `getDumper()` renders **HTML** (`maxDepth` — default 5, +`maxLength`/`maxItems`, `LOCATION_CLASS`, the shared `SNAPSHOT`, scrubber, +`keysToHide`) and feeds the page templates; `getAgentDumper()` renders +**text/markdown** (hardcoded depth 3, no snapshot, no location) and feeds only the +`agent.phtml` markdown variant. Note `keysToHide` includes `BlueScreen::$snapshot` +itself, so the internal snapshot never leaks into a dump. + +**Ordering invariant:** the shared snapshot is populated by reference *while* the +template renders each dump; its serialized form is written only at the very end of +`content.phtml` into ``. Moving that meta tag +before the dumps (or dumping after it) silently breaks collapsed-dump expansion on +the client. `renderTemplate` resets `$this->snapshot` both before and after the +`require`, so dumps outside that window are not captured. diff --git a/docs/internals/deferred-content.md b/docs/internals/deferred-content.md new file mode 100644 index 000000000..7ef1b5be7 --- /dev/null +++ b/docs/internals/deferred-content.md @@ -0,0 +1,57 @@ +# Deferred content: surviving redirects & riding AJAX + +The most counterintuitive mechanism in Tracy. The Bar and BlueScreen cannot always +render into the current response (a redirect has no body; an AJAX response is not +the page). `DeferredContent` bridges that gap through the **session**, and the +content is delivered by a **second HTTP request the browser makes for it**. + +## The three defer paths (from `Bar::render`) + +- **AJAX** → `addSetup('Tracy.Debug.loadAjax', )`. (The AJAX/deferred flag + is decided once in the `DeferredContent` constructor — `X-Tracy-Ajax` header + matching `^\w{10,15}$` — `Bar::render` only checks `isDeferred()`.) +- **Redirect** (a `Location:` header is present) → nothing is emitted; the content is + pushed onto a `redirect` queue in the session. +- **Normal HTML** → the main partial is rendered *and the redirect queue is drained* + (reversed, appended, then `null`ed), so content accumulated during prior redirects + finally appears on the next real page. BlueScreen's AJAX path is analogous: + `addSetup('Tracy.BlueScreen.loadAjax', )`. + +## `addSetup` writes JS into the session; the browser fetches it back + +`addSetup($method, $arg)` appends `"$method($arg);\n"` to +`getItems('setup')[$requestId]['code']` — and `getItems` returns a **reference into +the session data**, so the write lands directly in the session. The request that +*produces* debug output stores it under its own `requestId`; the browser then makes +a separate `GET ?_tracy_bar=content.` (or `content-ajax.`), which +`dispatch()`/`sendAssets()` answers by pulling the stored `code` out of the session, +**`unset`ting it (one-time consumption)**, and returning it as JavaScript. That is +how a redirect's Bar shows up after the redirect completes. + +`?_tracy_bar=js` serves the merged static assets once with a long `Cache-Control` +(the CSS is minified, the JS only IIFE-wrapped and concatenated). `clean()` keeps +only the last 10 items per key and only those younger than 60 seconds — and it runs +inside `sendAssets()` *before* the content fetch is answered, so a payload older +than 60 s is gone by the time the browser asks for it. **Every item stored in the +session must carry a `time` key**, or `clean()` silently discards it (`addSetup` +and the redirect push both stamp `time()`). + +**Ordering invariant:** `isAvailable()` is `$useSession && sessionStorage->isAvailable()`, +and `$useSession` is set **only inside `sendAssets()`**. So all deferral works only +because `dispatch()` → `sendAssets()` runs early in `enable()`; drop that call and +every `isAvailable()` gate in Bar/BlueScreen goes false, silently disabling deferral. + +## `FileSession` locking is coarse — and that is a trap + +The default `FileSession` (cookie `tracy-session`, file `tracy-`) takes a +**blocking `flock(LOCK_EX)`** on first access and **holds it for the entire +request**, writing and unlocking only in `__destruct`. Consequences to respect: + +- concurrent requests sharing the cookie (an AJAX call plus the main page) + **serialize** — they block each other; +- a crash without a clean shutdown **loses** the pending writes (no truncate/write); +- `isAvailable()` is **not** a read-only probe — it opens and locks the file. + +`FileSession` also has its own file GC, unrelated to `clean()`: session files older +than a week are deleted with probability 0.03 on open. `NativeSession` stores under +`$_SESSION['_tracy']` and is available only when a PHP session is active. diff --git a/docs/internals/dumper.md b/docs/internals/dumper.md new file mode 100644 index 000000000..a33328748 --- /dev/null +++ b/docs/internals/dumper.md @@ -0,0 +1,70 @@ +# Dumper + +Dumping is **two phases** and rendering (in the default `lazy = null` mode) is +**not single-pass**. + +## Describe → render + +`Dumper` is a facade over a `Describer` and a `Renderer`; `asHtml`/`asTerminal` run +`describe($var)` (phase 1) then `render($model)` (phase 2). + +- **`Describer`** produces a model `{value, snapshot, location}`. A scalar stays a + **native PHP value** only when the JSON round-trip is lossless — ints within the + JS-safe range, finite non-integer-valued floats, strings that `encodeString` + leaves unchanged; everything else (a short binary string, `5.0`, `NAN`, structures) + becomes a `Value` object — so the "tree" is a mix of native values and `Value`s. + `maxLength` truncation applies only at `depth > 0`; a top-level string is never + truncated. +- **`Exposer`** extracts object properties by reflection, including private/protected + (via mangled keys `"\x00Class\x00name"` / `"\x00*\x00name"`) and marks dynamic + properties. Exposer/exporter dispatch is **not insertion order**: `describe()` + `uksort`s `objectExposers` most-derived-first and the first match wins (`''` + matches everything). +- **`Renderer`** `match`-dispatches on `Value::Type*`. + +## The snapshot: objects/refs are stored once, referenced by placeholder + +Objects, resources, and referenced arrays are **not serialized inline**. Each is +put into a shared `snapshot` array keyed by `spl_object_id` / `r` / `p`, +and at the point of use a `Value` of type **`TypeRef`** is emitted. The renderer +dereferences a `TypeRef` back through the snapshot. This is why an object appearing +in many places is expanded once. Two invariants hang off this: + +- **`Value->holder` pins the live object** so GC cannot recycle its + `spl_object_id` — the snapshot key. Dropping `holder` allows key collisions in a + shared/live snapshot. +- **Infinite recursion is broken at describe time**: re-encountering an + object/array at equal-or-greater depth yields a `TypeRef` instead of descending. + +Three lazy modes drive how much goes to the client: + +- **`lazy = false`** — pure server-side HTML, no snapshot. +- **`lazy = true`** — the whole value goes into `data-tracy-dump` + the snapshot into + `data-tracy-snapshot`; the JS renders it. Only for non-empty arrays and objects — + a scalar falls through to the collapsed-parts branch and renders server-side. +- **`lazy = null`** (default, "collapsed parts") — HTML is rendered, but collapsed + nodes are serialized as refs and **only the reachable slice** of the snapshot + (`copySnapshot` → `snapshotSelection`) is emitted, so clicking a collapsed node + expands it from client-side data. + +For the Bar and BlueScreen the snapshot is **shared/live** across all dumps on the +page (`Dumper::$liveSnapshot` or a passed `SNAPSHOT` array + `collectingMode`) and +is written **once** for the whole page, by the templates themselves: they read +`$liveSnapshot[0]` / `BlueScreen::$snapshot[0]` directly into a +`` tag and then reset it. (The public +`formatSnapshotAttribute()` helper is for third-party integrations — nothing in +`src` calls it.) +In collecting mode `copySnapshot` is a **no-op** — the reachable-slice mechanism +applies only to standalone dumps; the live snapshot is emitted whole. + +## Depth, hiding, and cycles + +Defaults: `maxDepth = 7`, `maxLength = 150`, `maxItems = 100`. Sensitive values +(`SensitiveParameterValue`, the `scrubber`, or a key/`Class::$key` in `keysToHide`) +render as `***** (type)`. **Cycles are broken at describe time (the `TypeRef` +depth guard above) but classified at render time:** the renderer tracks `parents` +(open on the current path) and `above` (already rendered) by id, labelling a ref +`RECURSION` for a true cycle and `see above` / `see below` for a non-cyclic repeat. + +(There is no `Dumper::addExporter()` — object exporters are added to the static +`$objectExporters` / the `OBJECT_EXPORTERS` option.) diff --git a/docs/internals/error-handling.md b/docs/internals/error-handling.md new file mode 100644 index 000000000..75dfeb7ea --- /dev/null +++ b/docs/internals/error-handling.md @@ -0,0 +1,68 @@ +# Debugger bootstrap & error handling + +`Debugger::enable()` wires PHP's error machinery; the ordering is the non-local +knowledge. + +## `enable()` order + +1. **Mode gate** — sets `$productionMode` (an explicit bool is used directly, else + `!detectDebugMode($mode)`), but **only** when a `$mode` argument is passed or the + mode is still `Detect` — a repeated `enable()` without `$mode` does *not* + re-evaluate an already-resolved mode. +2. **Reserve memory / record `$time` / record `$obLevel`.** Note **`ob_start()` is + never called** — Tracy does *not* run its own output buffer; it only remembers + the buffer level at enable time and later strips buffers *above* it + (`removeOutputBuffers`). +3. Logging config (only overwritten if arguments passed), log-directory validation. +4. **PHP ini** (`display_errors=0`, `html_errors=0`, `log_errors=0`, + `zend.exception_ignore_args=0`) then **`error_reporting(E_ALL)`**. +5. **Strategy init + `dispatch()`** — *before* handler registration and *before* the + idempotence guard. Beware: for an asset/content sub-request + (`?_tracy_bar=…`), `DevelopmentStrategy::dispatch()` serves it and **`exit`s** — + `enable()` may never return; the same path sets `$assetsSent`, which suppresses + `renderBar()`. +6. **Idempotence guard** (`if ($enabled) return`). +7. **Handler registration, in this order:** `register_shutdown_function` **first**, + `set_exception_handler` **second** (its closure always ends `exit(255)`), + `set_error_handler` **third**. +8. `require_once` the internal classes, then `$enabled = true`. + +**A subtlety:** the ini/`error_reporting` block runs on *every* `enable()` call +(before the guard), but the handlers register only once. + +## Development vs Production strategy gates almost everything + +`getStrategy()` keys on `(int)(bool)$productionMode` → `DevelopmentStrategy` +(gets Bar + BlueScreen + `DeferredContent`) or `ProductionStrategy` (logs + a +neutral 500 page). `detectDebugMode` whitelists `REMOTE_ADDR` (localhost only when +no proxy header, `secret@addr` via the `tracy-debug` cookie). So whether an error +renders or is merely logged is decided entirely here — a common surprise in tests. + +## The handler flow + +- **`exceptionHandler`** — `$reserved` doubles as a **double-render guard** + (`$firstTime = (bool) $reserved; $reserved = null`). It snapshots the ob status, + sends HTTP 500, `removeOutputBuffers`, then delegates to + `strategy->handleException`. `$onFatalError` runs only on the first exception. + **The method itself never exits** — the `exit(255)` lives only in the closure + registered via `set_exception_handler`. Both `shutdownHandler` (which must + continue to free `$reserved` and render the Bar) and `enable()`'s log-dir + failure path (which adds its own explicit `exit(255)`) rely on that. +- **`errorHandler`** — `E_RECOVERABLE_ERROR`/`E_USER_ERROR` become a thrown + `ErrorException`; other errors are handled when `severity & error_reporting` **or** + `$scream` is set; it **returns `false` on purpose** so PHP's native handler still + fills `error_get_last()`. `$strictMode` is applied later, in + `DevelopmentStrategy::handleError` (not here); in production it is unused — + `$logSeverity` decides HTML-report vs plain-text log instead. +- **`shutdownHandler`** — catches fatals from `error_get_last()` (E_ERROR / + E_PARSE / …), rebuilds an `ErrorException` (optionally grafting a trace by + reflection), calls `exceptionHandler`, frees `$reserved`, and finally renders the + Bar if `$showBar`. + +`removeOutputBuffers` strips buffers above the recorded `$obLevel`, skipping +`ob_gzhandler`/zlib compression. It uses `ob_end_clean` only when an error occurred +**and** the buffer has no `chunk_size`; a streaming buffer (non-zero `chunk_size`) +is always flushed, even on error — that is why streamed output is not discarded by +a fatal. The Bar stays addable until it is rendered (at shutdown); +`dispatch()` must run *after* `session_start()` for `NativeSession`, or deferral is +unavailable. diff --git a/docs/internals/helpers.md b/docs/internals/helpers.md new file mode 100644 index 000000000..4584b8172 --- /dev/null +++ b/docs/internals/helpers.md @@ -0,0 +1,24 @@ +# Helpers gotchas + +Cross-cutting traps in `Helpers.php`; everything not listed here is clear from +the signatures. + +- **`improveException()` mutates the exception.** It rewrites the private + `$message` by reflection to append ", did you mean …?" and may set a dynamic + `$e->tracyAction` property (`{link, label}`) that `BlueScreen::renderActions()` + reads. Suggestions come from a weighted Levenshtein (`getSuggestion`), not a + plain edit distance. +- **`editorUri()` remaps paths through `Debugger::$editorMapping`** (`strtr`) + before substituting `%file`/`%line`/`%action`/… into `Debugger::$editor`. The + same mapping is applied to the display text in `editorLink()` and to the + `$browser` exec path in `DevelopmentStrategy`. Returns `null` when `$editor` + is unset or the file does not exist (except `action: 'create'`). +- **"Dumped from" locations can skip frames silently.** `findCallerLocation()` + ignores frames whose docblock contains `@tracySkipLocation` and frames under + `Debugger::$transparentPaths`. +- **`capture()` swallows output**: `ob_start(fn() => '')` with an output-eating + callback; on a throw it cleans the buffer and rethrows. Most template + rendering goes through it. +- **`isHtmlMode()` is the global "may I inject into this response" gate** — + false on AJAX (`X-Requested-With` / `X-Tracy-Ajax`), CLI, a missing + `HTTP_HOST`, or an already-sent non-`text/html` `Content-Type` header. diff --git a/docs/internals/js-contract.md b/docs/internals/js-contract.md new file mode 100644 index 000000000..b90f2a252 --- /dev/null +++ b/docs/internals/js-contract.md @@ -0,0 +1,102 @@ +# The PHP ↔ JS contract + +Tracy's client side (`bar.js`, `bluescreen.js`, `dumper.js`, `toggle.js`, …) and +its PHP side are coupled **entirely by strings** — function names, element ids, +attribute names, storage keys. Nothing checks the two sides against each other; +renaming either side breaks the other silently at runtime. This file lists the +load-bearing names. + +## Entry points: PHP emits JS calls as text + +`DeferredContent::addSetup($method, $argument)` appends the literal source +`"$method($argument);\n"` to the session; the browser executes it later. The +`$method` strings must match symbols the JS bundle defines on `window.Tracy`: + +| PHP emission | JS definition | +|---|---| +| `addSetup('Tracy.Debug.init', …)` (Bar) + inline in `loader.phtml` | `Debug.init` (bar.js) | +| `addSetup('Tracy.Debug.loadAjax', {bar, panels})` (Bar) | `Debug.loadAjax` (bar.js) | +| `addSetup('Tracy.BlueScreen.loadAjax', )` (BlueScreen) | `BlueScreen.loadAjax` (bluescreen.js) | +| `Tracy.BlueScreen.init()` inline in `page.phtml` | `BlueScreen.init` (bluescreen.js) | +| `addSetup('console.log' / 'console.error', )` — agent mode | browser built-ins | + +`Tracy.Debug.loadAjax` expects an **object** `{bar, panels}` of HTML strings; the +others take a single HTML string. Inline `` breakout. diff --git a/docs/internals/logger.md b/docs/internals/logger.md new file mode 100644 index 000000000..f28e9f93d --- /dev/null +++ b/docs/internals/logger.md @@ -0,0 +1,26 @@ +# Logger + +`Logger::log()` appends a text line to `.log` +(`file_put_contents(…, FILE_APPEND | LOCK_EX)`) and, for a `Throwable`, writes an +HTML BlueScreen report (plus a `.md` companion) — but only once per distinct +exception. + +## Hash-based deduplication + +`getExceptionFile()` hashes the whole exception chain — `[class, message, code, +file, line, trace-without-args]` — with **`xxh128`, truncated to 10 chars**. Because +the **arguments are stripped from the trace**, the same exception thrown with +different argument values produces the **same hash**. It then scans the directory +for an existing `….html`; if one exists it is **returned and not rewritten**, +so a recurring error only appends a line to `.log` while the HTML/MD report is +generated once. The report filename is `----.html` and +`renderToFile` opens it with `fopen(…, 'x')` (never overwrites). + +## Email snooze + +Emails are sent only for `ERROR`/`EXCEPTION`/`CRITICAL`, and rate-limited by a marker +file `email-sent`: the send condition is `filemtime('email-sent') + $snooze < +time()` **and** an atomic `file_put_contents('email-sent', 'sent')` in the same +expression — so a successful send both fires the mail and resets the snooze window +(`emailSnooze` default `'2 days'`, parsed via `strtotime`). The default mailer is +PHP `mail()` with a UTF-8 message and an `X-Mailer: Tracy` header. diff --git a/docs/internals/readme.md b/docs/internals/readme.md new file mode 100644 index 000000000..62b1afe09 --- /dev/null +++ b/docs/internals/readme.md @@ -0,0 +1,21 @@ +# Tracy internals + +How Tracy works underneath, for agents editing it. Several independent mechanisms +that share little context, so split by seam: + +- **[error-handling.md](error-handling.md)** — `Debugger::enable()`, handler + registration order, the Development/Production strategies, and fatal-error + capture. +- **[deferred-content.md](deferred-content.md)** — the counterintuitive mechanism + by which the Bar/BlueScreen survive a redirect or ride an AJAX response via the + session, plus `FileSession` locking. +- **[dumper.md](dumper.md)** — the two-phase describe→render pipeline and the + snapshot mechanism (rendering is not single-pass). +- **[bluescreen.md](bluescreen.md)** — the error page, repeated panel invocation, + code highlighting. +- **[bar.md](bar.md)** — the debug toolbar panel system and its deferred render. +- **[logger.md](logger.md)** — file logging, hash-based dedup, email snooze. +- **[js-contract.md](js-contract.md)** — the string-coupled PHP↔JS boundary: + entry points, the requestId round-trip, dump attributes, CSP nonce. +- **[helpers.md](helpers.md)** — cross-cutting `Helpers` gotchas (exception + mutation, editor mapping, output capture). From 199abfa0cd77658f8439c1c5ee128a98902a89e4 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 11 Jul 2026 17:34:13 +0200 Subject: [PATCH 08/37] Debugger: dump() with return: true respects $keysToHide and passes options in CLI mode --- src/Tracy/Debugger/Debugger.php | 3 ++- tests/Tracy/Debugger.dump().phpt | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Tracy/Debugger/Debugger.php b/src/Tracy/Debugger/Debugger.php index a32ae23dc..3ebf3ccae 100644 --- a/src/Tracy/Debugger/Debugger.php +++ b/src/Tracy/Debugger/Debugger.php @@ -488,9 +488,10 @@ public static function dump(mixed $var, bool $return = false): mixed Dumper::DEPTH => self::$maxDepth, Dumper::TRUNCATE => self::$maxLength, Dumper::ITEMS => self::$maxItems, + Dumper::KEYS_TO_HIDE => self::$keysToHide, ]; return Helpers::isCli() - ? Dumper::toText($var) + ? Dumper::toText($var, $options) : Helpers::capture(fn() => Dumper::dump($var, $options)); } elseif (!self::$productionMode) { diff --git a/tests/Tracy/Debugger.dump().phpt b/tests/Tracy/Debugger.dump().phpt index 96a4ea775..5252cf1d5 100644 --- a/tests/Tracy/Debugger.dump().phpt +++ b/tests/Tracy/Debugger.dump().phpt @@ -44,3 +44,13 @@ test('returned value', function () { $obj = new stdClass; Assert::same(Debugger::dump($obj), $obj); }); + + +test('keysToHide is applied with return: true', function () { + Debugger::$productionMode = false; + Debugger::$keysToHide = ['password']; + $output = Debugger::dump(['password' => 'secret'], return: true); + Assert::contains('*****', $output); + Assert::notContains('secret', $output); + Debugger::$keysToHide = []; +}); From 85d212fa7c67674f6b2947a70ac6638514a723da Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 11 Jul 2026 18:42:43 +0200 Subject: [PATCH 09/37] Debugger: extracted dumpOptions() and agentDumpOptions(), removes triple duplication of Dumper option assembly --- src/Tracy/Debugger/Debugger.php | 51 +++++++++++++++++---------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/src/Tracy/Debugger/Debugger.php b/src/Tracy/Debugger/Debugger.php index 3ebf3ccae..523e6eaa0 100644 --- a/src/Tracy/Debugger/Debugger.php +++ b/src/Tracy/Debugger/Debugger.php @@ -484,12 +484,7 @@ public static function getSessionStorage(): SessionStorage public static function dump(mixed $var, bool $return = false): mixed { if ($return) { - $options = [ - Dumper::DEPTH => self::$maxDepth, - Dumper::TRUNCATE => self::$maxLength, - Dumper::ITEMS => self::$maxItems, - Dumper::KEYS_TO_HIDE => self::$keysToHide, - ]; + $options = self::dumpOptions(); return Helpers::isCli() ? Dumper::toText($var, $options) : Helpers::capture(fn() => Dumper::dump($var, $options)); @@ -497,21 +492,14 @@ public static function dump(mixed $var, bool $return = false): mixed } elseif (!self::$productionMode) { $html = Helpers::isHtmlMode(); echo $html ? '' : ''; - Dumper::dump($var, [ - Dumper::DEPTH => self::$maxDepth, - Dumper::TRUNCATE => self::$maxLength, - Dumper::ITEMS => self::$maxItems, + Dumper::dump($var, self::dumpOptions() + [ Dumper::LOCATION => self::$showLocation, Dumper::THEME => self::$dumpTheme, - Dumper::KEYS_TO_HIDE => self::$keysToHide, ]); echo $html ? '' : ''; if ($html && Helpers::isAgent()) { - Helpers::consoleLog(Dumper::toText($var, [ - Dumper::DEPTH => 3, - Dumper::KEYS_TO_HIDE => self::$keysToHide, - ])); + Helpers::consoleLog(Dumper::toText($var, self::agentDumpOptions())); } } @@ -550,23 +538,38 @@ public static function barDump(mixed $var, ?string $title = null, array $options self::getBar()->addPanel($panel = new DefaultBarPanel('dumps'), 'Tracy:dumps'); } - $panel->data[] = ['title' => $title, 'dump' => Dumper::toHtml($var, $options + [ - Dumper::DEPTH => self::$maxDepth, - Dumper::ITEMS => self::$maxItems, - Dumper::TRUNCATE => self::$maxLength, + $panel->data[] = ['title' => $title, 'dump' => Dumper::toHtml($var, $options + self::dumpOptions() + [ Dumper::LOCATION => self::$showLocation ?: Dumper::LOCATION_CLASS | Dumper::LOCATION_SOURCE, Dumper::LAZY => true, - Dumper::KEYS_TO_HIDE => self::$keysToHide, - ]), 'text' => Helpers::isAgent() ? Dumper::toText($var, [ - Dumper::DEPTH => 3, - Dumper::KEYS_TO_HIDE => self::$keysToHide, - ]) : null]; + ]), 'text' => Helpers::isAgent() ? Dumper::toText($var, self::agentDumpOptions()) : null]; } return $var; } + /** @return array */ + private static function dumpOptions(): array + { + return [ + Dumper::DEPTH => self::$maxDepth, + Dumper::TRUNCATE => self::$maxLength, + Dumper::ITEMS => self::$maxItems, + Dumper::KEYS_TO_HIDE => self::$keysToHide, + ]; + } + + + /** @return array */ + private static function agentDumpOptions(): array + { + return [ + Dumper::DEPTH => 3, + Dumper::KEYS_TO_HIDE => self::$keysToHide, + ]; + } + + /** * Logs message or exception. */ From f43d646588319ce941ed8a8fd636c53dde1a27cb Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 11 Jul 2026 17:36:27 +0200 Subject: [PATCH 10/37] Dumper: $keysToHide and scrubber are applied to properties returned by __debugInfo and custom object exporters --- src/Tracy/Dumper/Describer.php | 5 ++++- tests/Dumper/Dumper.debugInfo.phpt | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Tracy/Dumper/Describer.php b/src/Tracy/Dumper/Describer.php index dc2ad65f6..5199c001a 100644 --- a/src/Tracy/Dumper/Describer.php +++ b/src/Tracy/Dumper/Describer.php @@ -193,7 +193,10 @@ private function describeObject(object $obj, int $depth = 0): Value $value->items = []; $props = $this->exposeObject($obj, $value); foreach ($props ?? [] as $k => $v) { - $this->addPropertyTo($value, (string) $k, $v, Value::PropertyVirtual, $this->getReferenceId($props ?? [], $k)); + $described = $this->isSensitive((string) $k, $v, get_debug_type($obj)) // props come from user callbacks (__debugInfo, custom exposers) + ? new Value(Value::TypeText, self::hideValue($v)) + : null; + $this->addPropertyTo($value, (string) $k, $v, Value::PropertyVirtual, $this->getReferenceId($props ?? [], $k), described: $described); } } diff --git a/tests/Dumper/Dumper.debugInfo.phpt b/tests/Dumper/Dumper.debugInfo.phpt index ee44c79de..24a263887 100644 --- a/tests/Dumper/Dumper.debugInfo.phpt +++ b/tests/Dumper/Dumper.debugInfo.phpt @@ -71,3 +71,27 @@ Assert::match( XX, Dumper::toText($container), ); + + +// KEYS_TO_HIDE is applied to keys returned by __debugInfo +Assert::match( + <<<'XX' + Password #%d% + password: ***** (string) + XX, + Dumper::toText($obj, [Dumper::DEBUGINFO => true, Dumper::KEYS_TO_HIDE => ['password']]), +); + + +// KEYS_TO_HIDE is applied to keys returned by custom object exporters +Assert::match( + <<<'XX' + Password #%d% + apiKey: ***** (string) + name: 'joe' + XX, + Dumper::toText($obj, [ + Dumper::OBJECT_EXPORTERS => [Password::class => fn() => ['apiKey' => 'secret', 'name' => 'joe']], + Dumper::KEYS_TO_HIDE => ['apiKey'], + ]), +); From 99791a9b97e6043e02ee1dd394cf29ac370c3f2c Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 18 Jul 2026 03:09:23 +0200 Subject: [PATCH 11/37] Dumper: support for modern PHP constructs - PHP 8.4 virtual hooked property renders as {virtual} instead of the misleading 'unset' - WeakReference shows the target object or (dead) - PHP 8.5 Uri\Rfc3986\Uri and Uri\WhatWg\Url show the address and components - Closure shows the class of bound $this - uninitialized lazy object exposes eagerly-initialized private and protected properties too and no longer throws on uninitialized ones --- src/Tracy/Dumper/Dumper.php | 6 +- src/Tracy/Dumper/Exposer.php | 73 +++++++++++++--- tests/Dumper/Dumper.exposers.modern.phpt | 107 +++++++++++++++++++++++ 3 files changed, 173 insertions(+), 13 deletions(-) create mode 100644 tests/Dumper/Dumper.exposers.modern.phpt diff --git a/src/Tracy/Dumper/Dumper.php b/src/Tracy/Dumper/Dumper.php index 5bee48b40..552967c9a 100644 --- a/src/Tracy/Dumper/Dumper.php +++ b/src/Tracy/Dumper/Dumper.php @@ -12,6 +12,7 @@ use Tracy\Dumper\Describer; use Tracy\Dumper\Exposer; use Tracy\Dumper\Renderer; +use Uri; use function array_flip, array_map, file_get_contents, fwrite, str_replace; use const STDOUT; @@ -69,7 +70,6 @@ class Dumper public static array $resources = [ 'stream' => 'stream_get_meta_data', 'stream-context' => 'stream_context_get_options', - 'curl' => 'curl_getinfo', ]; /** @var array */ @@ -82,6 +82,7 @@ class Dumper \__PHP_Incomplete_Class::class => [Exposer::class, 'exposePhpIncompleteClass'], \Generator::class => [Exposer::class, 'exposeGenerator'], \Fiber::class => [Exposer::class, 'exposeFiber'], + \CurlHandle::class => [Exposer::class, 'exposeCurl'], \DOMNode::class => [Exposer::class, 'exposeDOMNode'], \DOMNodeList::class => [Exposer::class, 'exposeDOMNodeList'], \DOMNamedNodeMap::class => [Exposer::class, 'exposeDOMNodeList'], @@ -96,6 +97,9 @@ class Dumper Ds\Heap::class => [Exposer::class, 'exposeDsCollection'], Ds\Map::class => [Exposer::class, 'exposeDsMap'], \WeakMap::class => [Exposer::class, 'exposeWeakMap'], + \WeakReference::class => [Exposer::class, 'exposeWeakReference'], + Uri\Rfc3986\Uri::class => [Exposer::class, 'exposeUri'], + Uri\WhatWg\Url::class => [Exposer::class, 'exposeUri'], ]; /** @var array */ diff --git a/src/Tracy/Dumper/Exposer.php b/src/Tracy/Dumper/Exposer.php index 589e53e5b..87106d7f3 100644 --- a/src/Tracy/Dumper/Exposer.php +++ b/src/Tracy/Dumper/Exposer.php @@ -9,7 +9,8 @@ use Dom; use Ds; -use function array_diff_key, array_key_exists, count, end, explode, get_mangled_object_vars, implode, iterator_to_array, preg_match_all, sort; +use Uri; +use function array_diff_key, array_key_exists, count, end, explode, get_debug_type, get_mangled_object_vars, implode, iterator_to_array, sort; use const PHP_VERSION_ID; @@ -55,13 +56,14 @@ public static function exposeObject(object $obj, Value $value, Describer $descri $describer->describeEnumProperty($class, $name, $values[$k]), ); } else { + $virtual = PHP_VERSION_ID >= 80400 && (new \ReflectionProperty($class, $name))->isVirtual(); $describer->addPropertyTo( $value, $name, null, $type, class: $class, - described: new Value(Value::TypeText, 'unset'), + described: new Value(Value::TypeText, $virtual ? '{virtual}' : 'unset'), ); } } @@ -115,6 +117,10 @@ public static function exposeClosure(\Closure $obj, Value $value, Describer $des $value->value .= '(' . implode(', ', $params) . ')'; + if ($this_ = $rc->getClosureThis()) { + $describer->addPropertyTo($value, 'this', null, described: new Value(Value::TypeText, get_debug_type($this_))); + } + $uses = []; $useValue = new Value(Value::TypeObject); $useValue->depth = $value->depth + 1; @@ -154,7 +160,13 @@ public static function exposeArrayObject(\ArrayObject $obj, Value $value, Descri public static function exposeDOMNode(\DOMNode|Dom\Node $obj, Value $value, Describer $describer): void { - $props = preg_match_all('#^\s*\[([^\]]+)] =>#m', print_r($obj, return: true), $tmp) ? $tmp[1] : []; + $props = []; + foreach ((new \ReflectionClass($obj))->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) { + if (!$prop->isStatic()) { + $props[] = $prop->getName(); + } + } + sort($props); foreach ($props as $p) { $describer->addPropertyTo($value, $p, @$obj->$p, Value::PropertyPublic); // @ some props may be deprecated @@ -206,6 +218,34 @@ public static function exposeSplFileInfo(\SplFileInfo $obj): array } + /** @return array */ + public static function exposeCurl(\CurlHandle $curl): array + { + return curl_getinfo($curl); + } + + + public static function exposeWeakReference(\WeakReference $ref, Value $value, Describer $describer): void + { + if ($obj = $ref->get()) { + $describer->addPropertyTo($value, 'object', $obj); + } else { + $value->value .= ' (dead)'; + } + } + + + public static function exposeUri(Uri\Rfc3986\Uri|Uri\WhatWg\Url $uri, Value $value, Describer $describer): void + { + $describer->addPropertyTo($value, 'uri', $uri instanceof Uri\WhatWg\Url ? $uri->toAsciiString() : $uri->toRawString()); + foreach ($uri->__debugInfo() as $k => $v) { + if ($v !== null && $v !== '') { + $describer->addPropertyTo($value, $k, $v); + } + } + } + + public static function exposeSplObjectStorage(\SplObjectStorage $obj, Value $value, Describer $describer): void { $value->value .= ' (' . count($obj) . ')'; @@ -293,16 +333,25 @@ public static function exposeDsMap( private static function exposeLazyObject(object $obj, Describer $describer, Value $value): void { $rc = new \ReflectionClass($obj); - foreach ($rc->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) { - if (!$prop->isLazy($obj)) { - $describer->addPropertyTo( - $value, - $prop->getName(), - $prop->getValue($obj), - Value::PropertyPublic, - described: $describer->describeEnumProperty($obj::class, $prop->getName(), $prop->getValue($obj)), - ); + foreach ($rc->getProperties() as $prop) { + if ($prop->isStatic() || $prop->isLazy($obj) || !$prop->isInitialized($obj)) { + continue; } + + $type = match (true) { + $prop->isPrivate() => Value::PropertyPrivate, + $prop->isProtected() => Value::PropertyProtected, + default => Value::PropertyPublic, + }; + $v = $prop->getValue($obj); + $describer->addPropertyTo( + $value, + $prop->getName(), + $v, + $type, + class: $prop->getDeclaringClass()->getName(), + described: $describer->describeEnumProperty($prop->getDeclaringClass()->getName(), $prop->getName(), $v), + ); } $value->value .= ' (lazy)'; diff --git a/tests/Dumper/Dumper.exposers.modern.phpt b/tests/Dumper/Dumper.exposers.modern.phpt new file mode 100644 index 000000000..acd5bfd2f --- /dev/null +++ b/tests/Dumper/Dumper.exposers.modern.phpt @@ -0,0 +1,107 @@ + "computed"; } + public string $plain = "a"; + };'); + + Assert::match( + <<<'XX' + class@anonymous #%d% + virtual: {virtual} + plain: 'a' + XX, + Dumper::toText($obj), + ); +}); + + +test('WeakReference shows the target object or (dead)', function () { + $obj = new stdClass; + $ref = WeakReference::create($obj); + Assert::match( + <<<'XX' + WeakReference #%d% + object: stdClass #%d% + XX, + Dumper::toText($ref), + ); + + $dead = WeakReference::create(new stdClass); + Assert::match('WeakReference (dead) #%d%', Dumper::toText($dead)); +}); + + +test('closure shows return type and bound $this', function () { + $host = new class { + public function make(): Closure + { + return fn(int $x): string => 'a'; + } + }; + + Assert::match( + <<<'XX' + Closure($x) #%d% + this: class@anonymous + XX, + Dumper::toText($host->make()), + ); +}); + + +test('Uri classes show the address and components', function () { + if (!class_exists(Uri\Rfc3986\Uri::class)) { + Tester\Environment::skip('Requires PHP 8.5 uri extension'); + } + + Assert::match( + <<<'XX' + Uri\Rfc3986\Uri #%d% + uri: 'https://example.com:8080/p?q=1#f' + scheme: 'https' + host: 'example.com' + port: 8080 + path: '/p' + query: 'q=1' + fragment: 'f' + XX, + Dumper::toText(Uri\Rfc3986\Uri::parse('https://example.com:8080/p?q=1#f')), + ); + + Assert::match( + <<<'XX' + Uri\WhatWg\Url #%d% + uri: 'https://example.com/p' + scheme: 'https' + host: 'example.com' + path: '/p' + XX, + Dumper::toText(Uri\WhatWg\Url::parse('https://example.com/p')), + ); +}); + + +test('CurlHandle shows request info', function () { + if (!extension_loaded('curl')) { + Tester\Environment::skip('Requires curl extension'); + } + + $curl = curl_init('http://example.com'); + Assert::match("CurlHandle #%d%\n url: 'http://example.com'%A%", Dumper::toText($curl)); +}); From 4a28ef5aaf5b41fcc1696a0f5a3072afbb7a4740 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 11 Jul 2026 17:38:01 +0200 Subject: [PATCH 12/37] FileSession: hardening (session file chmod 0600, unserialize without objects, SameSite + secure cookie on HTTPS) --- src/Tracy/Helpers.php | 9 ++++++++- src/Tracy/Session/FileSession.php | 12 ++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/Tracy/Helpers.php b/src/Tracy/Helpers.php index e439f0787..eedc7b683 100644 --- a/src/Tracy/Helpers.php +++ b/src/Tracy/Helpers.php @@ -218,7 +218,7 @@ public static function getSource(): string . (isset($_SERVER['argv']) ? ': ' . implode(' ', array_map(self::escapeArg(...), $_SERVER['argv'])) : ''); } elseif (isset($_SERVER['REQUEST_URI'])) { - return (!empty($_SERVER['HTTPS']) && strcasecmp($_SERVER['HTTPS'], 'off') ? 'https://' : 'http://') + return (self::isHttps() ? 'https://' : 'http://') . ($_SERVER['HTTP_HOST'] ?? '') . $_SERVER['REQUEST_URI']; @@ -228,6 +228,13 @@ public static function getSource(): string } + /** @internal */ + public static function isHttps(): bool + { + return !empty($_SERVER['HTTPS']) && strcasecmp($_SERVER['HTTPS'], 'off') !== 0; + } + + /** @internal */ public static function improveException(\Throwable $e): void { diff --git a/src/Tracy/Session/FileSession.php b/src/Tracy/Session/FileSession.php index f3cc3e218..d88f5a366 100644 --- a/src/Tracy/Session/FileSession.php +++ b/src/Tracy/Session/FileSession.php @@ -56,12 +56,20 @@ private function open(): void || !($file = @fopen($path = $this->dir . '/' . self::FilePrefix . $id, 'r+')) // intentionally @ ) { $id = Helpers::createId(); - setcookie($this->cookieName, $id, time() + self::CookieLifetime, '/', '', secure: false, httponly: true); + setcookie($this->cookieName, $id, [ + 'expires' => time() + self::CookieLifetime, + 'path' => '/', + 'secure' => Helpers::isHttps(), + 'httponly' => true, + 'samesite' => 'Lax', + ]); $file = @fopen($path = $this->dir . '/' . self::FilePrefix . $id, 'c+'); // intentionally @ if ($file === false) { throw new \RuntimeException("Unable to create file '$path'. " . (error_get_last()['message'] ?? '')); } + + @chmod($path, 0o600); // @ - may not be supported by the filesystem } if (!@flock($file, LOCK_EX)) { // intentionally @ @@ -69,7 +77,7 @@ private function open(): void } $this->file = $file; - $this->data = @unserialize(stream_get_contents($this->file)) ?: []; // @ - file may be empty + $this->data = @unserialize(stream_get_contents($this->file), ['allowed_classes' => false]) ?: []; // @ - file may be empty; data contain no objects if (mt_rand() / mt_getrandmax() < $this->gcProbability) { $this->clean(); From b316e94e9deec2eb52545cfd644b6c92fd7b8652 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 11 Jul 2026 17:38:49 +0200 Subject: [PATCH 13/37] DevelopmentStrategy: exception fallback output is HTML-escaped in HTML mode --- src/Tracy/Debugger/DevelopmentStrategy.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Tracy/Debugger/DevelopmentStrategy.php b/src/Tracy/Debugger/DevelopmentStrategy.php index 8c3a0a365..34e25ede4 100644 --- a/src/Tracy/Debugger/DevelopmentStrategy.php +++ b/src/Tracy/Debugger/DevelopmentStrategy.php @@ -51,10 +51,14 @@ public function handleException(\Throwable $exception, bool $firstTime): void private function renderExceptionCli(\Throwable $exception): void { + $esc = fn(string $s): string => Helpers::isHtmlMode() // exception message may contain user input + ? '
' . Helpers::escapeHtml($s) . '
' + : $s; + try { $logFile = Debugger::log($exception, Debugger::EXCEPTION); } catch (\Throwable $e) { - echo "$exception\nTracy is unable to log error: {$e->getMessage()}\n"; + echo $esc("$exception\nTracy is unable to log error: {$e->getMessage()}\n"); return; } @@ -66,7 +70,7 @@ private function renderExceptionCli(\Throwable $exception): void echo "\n\n" . CodeHighlighter::highlightPhpCli((string) file_get_contents($exception->getFile()), $exception->getLine()) . "\n"; } - echo "$exception\n" . ($logFile ? "\n(stored in $logFile)\n" : ''); + echo $esc("$exception\n" . ($logFile ? "\n(stored in $logFile)\n" : '')); if ($logFile && Debugger::$browser) { exec(Debugger::$browser . ' ' . escapeshellarg(strtr($logFile, Debugger::$editorMapping))); } From d6456b3b1e6da5c16444b84b7370fd5febd904e6 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 11 Jul 2026 17:39:33 +0200 Subject: [PATCH 14/37] BlueScreen: formatMessage() does not trigger autoloading of class names found in exception message --- src/Tracy/BlueScreen/BlueScreen.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Tracy/BlueScreen/BlueScreen.php b/src/Tracy/BlueScreen/BlueScreen.php index cba650830..a670f58f0 100644 --- a/src/Tracy/BlueScreen/BlueScreen.php +++ b/src/Tracy/BlueScreen/BlueScreen.php @@ -481,9 +481,10 @@ public function formatMessage(\Throwable $exception): string $msg = preg_replace_callback( '#(\w+\\\[\w\\\]+\w)(?:::(\w+))?#', function ($m) { - if (isset($m[2]) && method_exists($m[1], $m[2])) { + $classLike = class_exists($m[1], autoload: false) || interface_exists($m[1], autoload: false) || trait_exists($m[1], autoload: false); + if ($classLike && isset($m[2]) && method_exists($m[1], $m[2])) { $r = new \ReflectionMethod($m[1], $m[2]); - } elseif (class_exists($m[1], autoload: false) || interface_exists($m[1], autoload: false)) { + } elseif ($classLike) { $r = new \ReflectionClass($m[1]); } From fe326dc8c4e17d322154d74c977c6856832924c8 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 11 Jul 2026 17:40:58 +0200 Subject: [PATCH 15/37] Bar: fetch() monkey-patch no longer drops options argument and does not mutate caller's Request --- src/Tracy/Bar/assets/bar.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tracy/Bar/assets/bar.js b/src/Tracy/Bar/assets/bar.js index f90637e81..c08d679be 100644 --- a/src/Tracy/Bar/assets/bar.js +++ b/src/Tracy/Bar/assets/bar.js @@ -504,7 +504,7 @@ class Debug { let oldFetch = window.fetch; window.fetch = function (request, options) { - request = request instanceof Request ? request : new Request(request, options || {}); + request = new Request(request, options); let reqId = request.headers.get('X-Tracy-Ajax'); if (getOption('AutoRefresh') && !reqId && new URL(request.url, location.origin).host === location.host) { From 51a32448abe35952c605479421d6e559a9c0928f Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 11 Jul 2026 17:46:09 +0200 Subject: [PATCH 16/37] Logger: sendEmail() validates $emailSnooze, snooze check is atomic and marker is written only after successful sending - invalid $emailSnooze string (strtotime failure) silently disabled throttling and caused an email per error - concurrent requests could both pass the mtime check and send duplicate emails (TOCTOU) - when the mailer threw, the notification was lost for the whole snooze window --- src/Tracy/Helpers.php | 16 ++++++++ src/Tracy/Logger/Logger.php | 49 ++++++++++++++++------ tests/Tracy/Logger.sendEmail().phpt | 63 +++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 tests/Tracy/Logger.sendEmail().phpt diff --git a/src/Tracy/Helpers.php b/src/Tracy/Helpers.php index eedc7b683..fd58eb414 100644 --- a/src/Tracy/Helpers.php +++ b/src/Tracy/Helpers.php @@ -235,6 +235,22 @@ public static function isHttps(): bool } + /** + * Converts a time interval like '2 days' or a number of seconds to seconds. + * @internal + */ + public static function parseInterval(string|int $value): int + { + if (is_numeric($value)) { + return (int) $value; + } elseif (($time = strtotime((string) $value)) !== false) { + return $time - time(); + } + + throw new \InvalidArgumentException("Invalid time interval '$value'."); + } + + /** @internal */ public static function improveException(\Throwable $e): void { diff --git a/src/Tracy/Logger/Logger.php b/src/Tracy/Logger/Logger.php index e5f21e399..0a22a12e9 100644 --- a/src/Tracy/Logger/Logger.php +++ b/src/Tracy/Logger/Logger.php @@ -8,7 +8,7 @@ namespace Tracy; use function in_array, is_string; -use const DIRECTORY_SEPARATOR, FILE_APPEND, LOCK_EX, PHP_EOL; +use const DIRECTORY_SEPARATOR, FILE_APPEND, LOCK_EX, LOCK_UN, PHP_EOL; /** @@ -164,17 +164,42 @@ protected function logException(\Throwable $exception, ?string $file = null): st */ protected function sendEmail($message): void { - $snooze = is_numeric($this->emailSnooze) - ? $this->emailSnooze - : strtotime($this->emailSnooze) - time(); - - if ( - $this->email - && $this->mailer - && @filemtime($this->directory . '/email-sent') + $snooze < time() // @ file may not exist - && @file_put_contents($this->directory . '/email-sent', 'sent') // @ file may not be writable - ) { - ($this->mailer)($message, implode(', ', (array) $this->email)); + if ($this->email && $this->mailer) { + $this->throttle( + $this->directory . '/email-sent', + Helpers::parseInterval($this->emailSnooze), + fn() => ($this->mailer)($message, implode(', ', (array) $this->email)), + ); + } + } + + + /** + * Executes the action at most once per given interval. The check is atomic across + * concurrent requests and the interval restarts only after the action succeeds. + * @param \Closure(): void $action + */ + private function throttle(string $lockFile, int $interval, \Closure $action): void + { + $handle = @fopen($lockFile, 'c+'); // @ - file may not be writable + if (!$handle) { + return; + } + + try { + if (!flock($handle, LOCK_EX)) { + return; + } + + $stat = fstat($handle); + if ($stat['size'] === 0 || $stat['mtime'] + $interval < time()) { + $action(); + ftruncate($handle, 0); + fwrite($handle, date('c')); + } + } finally { + flock($handle, LOCK_UN); + fclose($handle); } } diff --git a/tests/Tracy/Logger.sendEmail().phpt b/tests/Tracy/Logger.sendEmail().phpt new file mode 100644 index 000000000..bc1428f44 --- /dev/null +++ b/tests/Tracy/Logger.sendEmail().phpt @@ -0,0 +1,63 @@ +email = 'admin@example.com'; + $sent = []; + $logger->mailer = function ($message, $email) use (&$sent) { + $sent[] = $message; + }; + + $logger->log('first', Logger::ERROR); + $logger->log('second', Logger::ERROR); + Assert::same(['first'], $sent); + Assert::true(is_file(getTempDir() . '/email-sent')); +}); + + +test('failed sending is not marked as sent', function () { + $logger = new Logger(getTempDir() . '/fail'); + mkdir(getTempDir() . '/fail'); + $logger->email = 'admin@example.com'; + $calls = 0; + $logger->mailer = function () use (&$calls) { + $calls++; + throw new RuntimeException('SMTP down'); + }; + + Assert::exception(fn() => $logger->log('first', Logger::ERROR), RuntimeException::class, 'SMTP down'); + Assert::exception(fn() => $logger->log('second', Logger::ERROR), RuntimeException::class, 'SMTP down'); + Assert::same(2, $calls); // next error retries, notification is not lost + + $logger->mailer = function () use (&$calls) { + $calls++; + }; + $logger->log('third', Logger::ERROR); + $logger->log('fourth', Logger::ERROR); + Assert::same(3, $calls); // sent once, then snoozed +}); + + +test('invalid emailSnooze is reported', function () { + $logger = new Logger(getTempDir() . '/invalid'); + mkdir(getTempDir() . '/invalid'); + $logger->email = 'admin@example.com'; + $logger->emailSnooze = 'nonsense'; + $logger->mailer = function () {}; + + Assert::exception( + fn() => $logger->log('boom', Logger::ERROR), + InvalidArgumentException::class, + "Invalid time interval 'nonsense'.", + ); +}); From 86975360f70b46d9da75695ac5ec8a007e6fb1e1 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 11 Jul 2026 17:47:40 +0200 Subject: [PATCH 17/37] assets: corrupt localStorage/sessionStorage entry no longer breaks Bar/Toggle init, keyboard activation of toggle links works; FileSession: concurrent GC no longer triggers warnings --- src/Tracy/Bar/assets/bar.js | 12 ++++++++++-- src/Tracy/Session/FileSession.php | 4 ++-- src/Tracy/assets/toggle.js | 9 +++++++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/Tracy/Bar/assets/bar.js b/src/Tracy/Bar/assets/bar.js index c08d679be..8f2feb2af 100644 --- a/src/Tracy/Bar/assets/bar.js +++ b/src/Tracy/Bar/assets/bar.js @@ -23,6 +23,14 @@ function getOption(key) { return global === undefined ? defaults[key] : global; } +function restoreJSON(key) { + try { + return JSON.parse(localStorage.getItem(key)); + } catch { + return null; // ignore corrupt data + } +} + class Panel { constructor(id) { this.id = id; @@ -221,7 +229,7 @@ class Panel { restorePosition() { let key = this.id.split(':')[0]; - let pos = JSON.parse(localStorage.getItem(key)); + let pos = restoreJSON(key); if (!pos) { this.elem.classList.add(Panel.PEEK); } else if (pos.window) { @@ -379,7 +387,7 @@ class Bar { restorePosition() { - let pos = JSON.parse(localStorage.getItem(this.id)); + let pos = restoreJSON(this.id); setPosition(this.elem, pos || { right: 0, bottom: 0 }); this.savePosition(); } diff --git a/src/Tracy/Session/FileSession.php b/src/Tracy/Session/FileSession.php index d88f5a366..2c13182f4 100644 --- a/src/Tracy/Session/FileSession.php +++ b/src/Tracy/Session/FileSession.php @@ -95,8 +95,8 @@ public function clean(): void { $old = strtotime('-1 week'); foreach (glob($this->dir . '/' . self::FilePrefix . '*') ?: [] as $file) { - if (filemtime($file) < $old) { - unlink($file); + if (@filemtime($file) < $old) { // @ - file may be deleted by concurrent GC + @unlink($file); // @ - file may be deleted by concurrent GC } } } diff --git a/src/Tracy/assets/toggle.js b/src/Tracy/assets/toggle.js index aabefb5f1..9643c16d2 100644 --- a/src/Tracy/assets/toggle.js +++ b/src/Tracy/assets/toggle.js @@ -17,7 +17,7 @@ class Toggle { if ( !e.shiftKey && !e.ctrlKey && !e.metaKey && (el = e.target.closest('.tracy-toggle')) - && Math.pow(start[0] - e.clientX, 2) + Math.pow(start[1] - e.clientY, 2) < MOVE_THRESHOLD + && (!start || Math.pow(start[0] - e.clientX, 2) + Math.pow(start[1] - e.clientY, 2) < MOVE_THRESHOLD) // no start = keyboard activation ) { Toggle.toggle(el, undefined, e); e.preventDefault(); @@ -73,7 +73,12 @@ class Toggle { } }); - let toggles = JSON.parse(sessionStorage.getItem('tracy-toggles-' + baseEl.id)); + let toggles; + try { + toggles = JSON.parse(sessionStorage.getItem('tracy-toggles-' + baseEl.id)); + } catch { + // ignore corrupt data + } if (toggles && restore !== false) { toggles.forEach((item) => { let el = baseEl; From f6d54376effbb96623d9e8d3e0a3cb52a01a9da7 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 11 Jul 2026 17:48:59 +0200 Subject: [PATCH 18/37] TracyToPsrLoggerAdapter: exception is not logged twice when message adds no information; Stringable message is cast to string --- src/Bridges/Psr/TracyToPsrLoggerAdapter.php | 7 ++++++- tests/Tracy.Bridges/TracyToPsrLoggerAdapter.phpt | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Bridges/Psr/TracyToPsrLoggerAdapter.php b/src/Bridges/Psr/TracyToPsrLoggerAdapter.php index e460082b1..bf927abce 100644 --- a/src/Bridges/Psr/TracyToPsrLoggerAdapter.php +++ b/src/Bridges/Psr/TracyToPsrLoggerAdapter.php @@ -39,10 +39,15 @@ public function __construct( public function log($level, $message, array $context = []): void { $level = self::LevelMap[$level] ?? Tracy\ILogger::ERROR; + $message = (string) $message; if (isset($context['exception']) && $context['exception'] instanceof \Throwable) { - $this->tracyLogger->log($context['exception'], $level); + $exception = $context['exception']; unset($context['exception']); + $this->tracyLogger->log($exception, $level); + if (!$context && ($message === '' || $message === $exception->getMessage())) { + return; // exception entry already carries all the information + } } if ($context) { diff --git a/tests/Tracy.Bridges/TracyToPsrLoggerAdapter.phpt b/tests/Tracy.Bridges/TracyToPsrLoggerAdapter.phpt index f66513fdb..e7357bd49 100644 --- a/tests/Tracy.Bridges/TracyToPsrLoggerAdapter.phpt +++ b/tests/Tracy.Bridges/TracyToPsrLoggerAdapter.phpt @@ -33,6 +33,8 @@ $psrLogger->warning('warning'); $psrLogger->error('order failed with exception', ['exception' => $exception]); $psrLogger->error('order failed with context', ['orderId' => 123]); $psrLogger->error('order failed with context and exception', ['orderId' => 123, 'exception' => $exception]); +$psrLogger->error($exception->getMessage(), ['exception' => $exception]); // message adds no information +$psrLogger->error('', ['exception' => $exception]); Assert::same([ [ILogger::INFO, 'info'], @@ -42,4 +44,6 @@ Assert::same([ [ILogger::ERROR, ['message' => 'order failed with context', 'context' => ['orderId' => 123]]], [ILogger::ERROR, $exception], [ILogger::ERROR, ['message' => 'order failed with context and exception', 'context' => ['orderId' => 123]]], + [ILogger::ERROR, $exception], + [ILogger::ERROR, $exception], ], $tracyLogger->entries); From 9ed87814909578492d6055c9566c9c811eac9b57 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 11 Jul 2026 17:51:46 +0200 Subject: [PATCH 19/37] TracyExtension: 'editor: false' in config disables editor links --- src/Bridges/Nette/TracyExtension.php | 18 ++++++----- .../Tracy.Bridges/TracyExtension.editor.phpt | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 tests/Tracy.Bridges/TracyExtension.editor.phpt diff --git a/src/Bridges/Nette/TracyExtension.php b/src/Bridges/Nette/TracyExtension.php index e9e6e726a..b9ee02594 100644 --- a/src/Bridges/Nette/TracyExtension.php +++ b/src/Bridges/Nette/TracyExtension.php @@ -22,7 +22,7 @@ * fromEmail: string|null, * emailSnooze: string|null, * logSeverity: int|string|list|null, - * editor: string|null, + * editor: string|false|null, * browser: string|null, * errorTemplate: string|null, * strictMode: bool|int|string|list|null, @@ -62,7 +62,7 @@ public function getConfigSchema(): Nette\Schema\Schema 'fromEmail' => Expect::email()->dynamic(), 'emailSnooze' => Expect::string()->dynamic(), 'logSeverity' => Expect::anyOf(Expect::int(), $errorSeverityExpr, Expect::listOf($errorSeverity)), - 'editor' => Expect::type('string|null')->dynamic(), + 'editor' => Expect::anyOf(Expect::string(), false, null)->dynamic(), 'browser' => Expect::string()->dynamic(), 'errorTemplate' => Expect::string()->dynamic(), 'strictMode' => Expect::anyOf(Expect::bool(), Expect::int(), $errorSeverityExpr, Expect::listOf($errorSeverity)), @@ -136,12 +136,16 @@ public function afterCompile(Nette\PhpGenerator\ClassType $class): void ]; foreach ($options as $key => $value) { - if ($value !== null) { - $initialize->addBody($builder->formatPhp( - ($special[$key] ?? 'Tracy\Debugger::$' . $key . ' = ?') . ';', - Nette\DI\Helpers::filterArguments([$value]), - )); + if ($key === 'editor' && $value === false) { + $value = null; // 'editor: false' disables editor links + } elseif ($value === null) { + continue; } + + $initialize->addBody($builder->formatPhp( + ($special[$key] ?? 'Tracy\Debugger::$' . $key . ' = ?') . ';', + Nette\DI\Helpers::filterArguments([$value]), + )); } if ($config->netteMailer && $builder->getByType(Nette\Mail\IMailer::class)) { diff --git a/tests/Tracy.Bridges/TracyExtension.editor.phpt b/tests/Tracy.Bridges/TracyExtension.editor.phpt new file mode 100644 index 000000000..dbbb63ed8 --- /dev/null +++ b/tests/Tracy.Bridges/TracyExtension.editor.phpt @@ -0,0 +1,30 @@ +setClassName('Container1'); +$compiler->addExtension('tracy', new TracyExtension); +$compiler->addConfig([ + 'tracy' => [ + 'editor' => false, + ], +]); + +eval($compiler->compile()); + +Tracy\Debugger::enable(); + +$container = new Container1; +$container->initialize(); + +Assert::null(Tracy\Debugger::$editor); From a4931b8bb9806bcbfcab501e365f68af0e140acc Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 18 Jul 2026 03:20:29 +0200 Subject: [PATCH 20/37] DeferredContent: repeated enable() is a no-op sendAssets() no longer throws when a second enable() runs after output has started and there is nothing to send. --- src/Tracy/Debugger/DeferredContent.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Tracy/Debugger/DeferredContent.php b/src/Tracy/Debugger/DeferredContent.php index 8419de797..cb40be198 100644 --- a/src/Tracy/Debugger/DeferredContent.php +++ b/src/Tracy/Debugger/DeferredContent.php @@ -67,14 +67,17 @@ public function addSetup(string $method, mixed $argument): void public function sendAssets(): bool { + $asset = $_GET['_tracy_bar'] ?? null; if (headers_sent($file, $line) || ob_get_length()) { + if ($asset === null && !$this->deferred) { // nothing to send, repeated enable() is a no-op + return false; + } + throw new \LogicException( __METHOD__ . '() called after some output has been sent. ' . ($file ? "Output started at $file:$line." : 'Try Tracy\OutputDebugger to find where output started.'), ); } - - $asset = $_GET['_tracy_bar'] ?? null; if ($asset === 'js') { header('Content-Type: application/javascript; charset=UTF-8'); header('Cache-Control: max-age=864000'); From 38213afe1d8d3255c5c493f0b629885e7042fce5 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 18 Jul 2026 03:20:29 +0200 Subject: [PATCH 21/37] Describer: exposer ordering comparator is a valid total order, describeVar() dispatches via match - the comparator returns 0 for unrelated pairs and understands interface-keyed exposers (class_exists() is false for interfaces) - describeVar() uses match instead of building a method name from gettype(), so the calls are visible to static analysis --- src/Tracy/Dumper/Describer.php | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Tracy/Dumper/Describer.php b/src/Tracy/Dumper/Describer.php index 5199c001a..76a938c36 100644 --- a/src/Tracy/Dumper/Describer.php +++ b/src/Tracy/Dumper/Describer.php @@ -8,7 +8,7 @@ namespace Tracy\Dumper; use Tracy\Helpers; -use function array_map, array_slice, class_exists, count, explode, file, get_debug_type, get_resource_type, gettype, htmlspecialchars, implode, is_bool, is_int, is_resource, is_string, is_subclass_of, json_encode, method_exists, preg_match, spl_object_id, str_replace, strlen, strpos, strtolower, trim, uksort; +use function array_map, array_slice, class_exists, count, file, get_debug_type, get_resource_type, htmlspecialchars, implode, interface_exists, is_array, is_bool, is_float, is_int, is_object, is_resource, is_string, is_subclass_of, json_encode, method_exists, preg_match, spl_object_id, str_replace, strlen, strpos, strtolower, trim, uksort; /** @@ -53,7 +53,10 @@ final class Describer public function describe(mixed $var): \stdClass { - uksort($this->objectExposers, fn($a, $b): int => $b === '' || (class_exists($a, autoload: false) && is_subclass_of($a, $b)) ? -1 : 1); + // exposers are sorted from the most specific type to the most general; '' acts as the universal supertype + $isSubtypeOf = fn(string $type, string $parent): bool => $parent === '' + || ((class_exists($type, autoload: false) || interface_exists($type, autoload: false)) && is_subclass_of($type, $parent)); + uksort($this->objectExposers, fn($a, $b): int => $isSubtypeOf($b, $a) <=> $isSubtypeOf($a, $b)); try { return (object) [ @@ -72,12 +75,15 @@ public function describe(mixed $var): \stdClass private function describeVar(mixed $var, int $depth = 0, ?int $refId = null): mixed { - if ($var === null || is_bool($var)) { - return $var; - } - - $m = 'describe' . explode(' ', gettype($var))[0]; - return $this->$m($var, $depth, $refId); + return match (true) { + $var === null, is_bool($var) => $var, + is_int($var) => $this->describeInteger($var), + is_float($var) => $this->describeDouble($var), + is_string($var) => $this->describeString($var, $depth), + is_array($var) => $this->describeArray($var, $depth, $refId), + is_object($var) => $this->describeObject($var, $depth), + default => $this->describeResource($var, $depth), // open or closed resource + }; } From 2b6ae998c0c1e7eb1af678ee02f709c5c0607f16 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Sat, 11 Jul 2026 18:34:15 +0200 Subject: [PATCH 22/37] assets: removed legacy browser workarounds - getAttribute('href', 2) IE flag, keyCode, navigator.platform, window.screenLeft feature test, document.write() in the panel popup - evalScripts() no longer accumulates executed \n"; + $s = Helpers::minifyJs($s) + . 'window.Tracy.css.shared = ' . json_encode(Helpers::minifyCss($css)) . ';'; // dumper reads the registry lazily, after the minified code + echo "", str_replace(['