diff --git a/.gitattributes b/.gitattributes index 433a2de9a..9cdfca08c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,10 +1,15 @@ .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 *.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/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/composer.json b/composer.json index 1c213cbfb..a772617ef 100644 --- a/composer.json +++ b/composer.json @@ -44,11 +44,12 @@ "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": { - "dev-master": "2.12-dev" + "dev-master": "3.0-dev" } }, "config": { 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). 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 @@ +|null, + * fromEmail: string|null, + * emailSnooze: string|null, + * logSeverity: int|string|list|null, + * editor: string|false|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 { @@ -39,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)), @@ -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; }'); @@ -89,7 +111,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,22 +125,27 @@ 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 . ' = ?') . ';', - 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)) { @@ -129,14 +156,14 @@ public function afterCompile(Nette\PhpGenerator\ClassType $class): void } $initialize->addBody($builder->formatPhp('if ($logger instanceof Tracy\Logger) $logger->mailer = ?;', [ - [new Statement(Tracy\Bridges\Nette\MailSender::class, $params), 'send'], + [new Statement(Tracy\Bridges\Nette\MailSender::class, $params), 'send'], // TODO: nette/di must be able to create closures ])); } if ($this->debugMode) { foreach ($config->bar as $item) { if (is_string($item) && str_starts_with($item, '@')) { - $item = new Statement(['@' . $builder::THIS_CONTAINER, 'getService'], [substr($item, 1)]); + $item = new Statement(['@' . $builder::ThisContainer, 'getService'], [substr($item, 1)]); } elseif (is_string($item)) { $item = new Statement($item); } 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/src/Tracy/Bar/assets/bar.css b/src/Tracy/Bar/assets/bar.css index 5f71d82b8..0bef18bc0 100644 --- a/src/Tracy/Bar/assets/bar.css +++ b/src/Tracy/Bar/assets/bar.css @@ -2,40 +2,40 @@ * This file is part of the Tracy (https://tracy.nette.org) */ -/* common styles */ -#tracy-debug { +@layer tracy-components { + +/* shadow DOM host */ +:host { --tracy-space: 10px; - display: none; direction: ltr; + display: none; } -body#tracy-debug { /* in popup window */ +body#tracy-debug { /* in popup window, the sheet is adopted by the document */ + --tracy-space: 10px; + direction: ltr; display: block; } -#tracy-debug:not(body) { - position: absolute; - left: 0; - top: 0; -} - -#tracy-debug a { +/* common styles */ +a { color: #125EAE; text-decoration: none; } -#tracy-debug a:hover, -#tracy-debug a:focus { +a:hover, +a:focus { background-color: #125EAE; color: white; } -#tracy-debug h2, -#tracy-debug h3 { +h2, +h3 { + font-size: inherit; font-weight: bold; } -#tracy-debug :where(:is( +:where(:is( h1, h2, h3, h4, h5, h6, p, ol, ul, dl, @@ -47,44 +47,44 @@ body#tracy-debug { /* in popup window */ margin-top: var(--tracy-space); } -#tracy-debug table { +table { background: #FDF5CE; width: 100%; } -#tracy-debug tr:nth-child(2n) td { +tr:nth-child(2n) td { background: rgba(0, 0, 0, 0.02); } -#tracy-debug td, -#tracy-debug th { +td, +th { border: 1px solid #E6DFBF; padding: 2px 5px; vertical-align: top; text-align: left; } -#tracy-debug th { +th { background: #F4F3F1; color: #655E5E; font-size: 90%; font-weight: bold; } -#tracy-debug pre, -#tracy-debug code { +pre, +code { font: 9pt/1.5 Consolas, monospace; } -#tracy-debug table .tracy-right { +table .tracy-right { text-align: right; } -#tracy-debug svg { +svg { display: inline; } -#tracy-debug .tracy-dump { +.tracy-dump { margin: 0; padding: 2px 5px; } @@ -182,7 +182,7 @@ body#tracy-debug { /* in popup window */ /* panels */ -#tracy-debug .tracy-panel { +.tracy-panel { display: none; font: normal normal 12px/1.5 sans-serif; background: white; @@ -194,7 +194,7 @@ body#tracy-debug .tracy-panel { /* in popup window */ display: block; } -#tracy-debug h1 { +h1 { font: normal normal 23px/1.4 Tahoma, sans-serif; line-height: 1; color: #575753; @@ -202,29 +202,29 @@ body#tracy-debug .tracy-panel { /* in popup window */ word-wrap: break-word; } -#tracy-debug .tracy-inner { +.tracy-inner { overflow: auto; flex: 1; } -#tracy-debug .tracy-panel .tracy-icons { +.tracy-panel .tracy-icons { display: none; } -#tracy-debug .tracy-panel-ajax h1::after, -#tracy-debug .tracy-panel-redirect h1::after { +.tracy-panel-ajax h1::after, +.tracy-panel-redirect h1::after { content: 'ajax'; float: right; font-size: 65%; margin: 0 .3em; } -#tracy-debug .tracy-panel-redirect h1::after { +.tracy-panel-redirect h1::after { content: 'redirect'; } -#tracy-debug .tracy-mode-peek, -#tracy-debug .tracy-mode-float { +.tracy-mode-peek, +.tracy-mode-float { position: fixed; flex-direction: column; padding: var(--tracy-space); @@ -235,24 +235,24 @@ body#tracy-debug .tracy-panel { /* in popup window */ border: 1px solid rgba(0, 0, 0, 0.1); } -#tracy-debug .tracy-mode-peek, -#tracy-debug .tracy-mode-float:not(.tracy-panel-resized) { +.tracy-mode-peek, +.tracy-mode-float:not(.tracy-panel-resized) { max-width: 700px; max-height: 500px; } @media (max-height: 555px) { - #tracy-debug .tracy-mode-peek, - #tracy-debug .tracy-mode-float:not(.tracy-panel-resized) { + .tracy-mode-peek, + .tracy-mode-float:not(.tracy-panel-resized) { max-height: 100vh; } } -#tracy-debug .tracy-mode-peek h1 { +.tracy-mode-peek h1 { cursor: move; } -#tracy-debug .tracy-mode-float { +.tracy-mode-float { display: flex; opacity: .95; transition: opacity 0.2s; @@ -261,18 +261,18 @@ body#tracy-debug .tracy-panel { /* in popup window */ resize: both; } -#tracy-debug .tracy-focused { +.tracy-focused { display: flex; opacity: 1; transition: opacity 0.1s; } -#tracy-debug .tracy-mode-float h1 { +.tracy-mode-float h1 { cursor: move; padding-right: 25px; } -#tracy-debug .tracy-mode-float .tracy-icons { +.tracy-mode-float .tracy-icons { display: block; position: absolute; top: 0; @@ -280,25 +280,27 @@ body#tracy-debug .tracy-panel { /* in popup window */ font-size: 18px; } -#tracy-debug .tracy-mode-window { +.tracy-mode-window { padding: var(--tracy-space); } -#tracy-debug .tracy-icons a { +.tracy-icons a { color: #575753; } -#tracy-debug .tracy-icons a:hover { +.tracy-icons a:hover { color: white; } -#tracy-debug .tracy-inner-container { +.tracy-inner-container { min-width: fit-content; } @media print { - #tracy-debug * { + * { display: none; } } + +} diff --git a/src/Tracy/Bar/assets/bar.js b/src/Tracy/Bar/assets/bar.js index f90637e81..66af774a2 100644 --- a/src/Tracy/Bar/assets/bar.js +++ b/src/Tracy/Bar/assets/bar.js @@ -2,9 +2,7 @@ * This file is part of the Tracy (https://tracy.nette.org) */ -if (navigator.webdriver) { - document.cookie = 'tracy-webdriver=1;path=/;SameSite=Lax'; -} +document.cookie = navigator.webdriver ? 'tracy-webdriver=1;path=/;SameSite=Lax' : 'tracy-webdriver=;path=/;Max-Age=0;SameSite=Lax'; let requestId = document.currentScript.dataset.id, ajaxCounter = 1, @@ -23,10 +21,18 @@ 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; - this.elem = document.getElementById(this.id); + this.elem = Debug.shadow.querySelector('#' + CSS.escape(this.id)); this.elem.Tracy = this.elem.Tracy || {}; } @@ -37,7 +43,7 @@ class Panel { this.init = function () {}; elem.innerHTML = elem.tracyContent = elem.dataset.tracyContent; delete elem.dataset.tracyContent; - Tracy.Dumper.init(Debug.layer); + Tracy.Dumper.init(Debug.shadow); evalScripts(elem); draggable(elem, { @@ -144,8 +150,8 @@ class Panel { toWindow() { let offset = getOffset(this.elem); - offset.left += typeof window.screenLeft === 'number' ? window.screenLeft : (window.screenX + 10); - offset.top += typeof window.screenTop === 'number' ? window.screenTop : (window.screenY + 50); + offset.left += window.screenLeft; + offset.top += window.screenTop; let win = window.open('', this.id.replace(/-/g, '_'), 'left=' + offset.left + ',top=' + offset.top + ',width=' + this.elem.offsetWidth + ',height=' + this.elem.offsetHeight + ',resizable=yes,scrollbars=yes'); @@ -154,19 +160,23 @@ class Panel { } let doc = win.document; - doc.write(''); + doc.head.appendChild(doc.createElement('meta')).setAttribute('charset', 'utf-8'); + doc.body.id = 'tracy-debug'; let script = doc.createElement('script'); script.src = baseUrl + '_tracy_bar=js&XDEBUG_SESSION_STOP=1'; script.async = true; - script.addEventListener('load', () => win.Tracy.Dumper.init()); + script.addEventListener('load', () => { + win.Tracy.adoptStyleSheets(doc, ['shared', 'bar']); + win.Tracy.Dumper.init(); + }); doc.head.appendChild(script); let meta = this.elem.parentElement.lastElementChild; - doc.body.innerHTML = '' + doc.body.innerHTML = '
' + '
' + this.elem.tracyContent + '
' + meta.outerHTML - + ''; + + '
'; evalScripts(doc.body); if (this.elem.querySelector('h1')) { doc.title = this.elem.querySelector('h1').textContent; @@ -178,7 +188,7 @@ class Panel { }); doc.addEventListener('keyup', (e) => { - if (e.keyCode === 27 && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) { + if (e.key === 'Escape' && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) { win.close(); } }); @@ -221,7 +231,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) { @@ -253,7 +263,7 @@ Panel.zIndexCounter = 1; class Bar { init() { this.id = 'tracy-debug-bar'; - this.elem = document.getElementById(this.id); + this.elem = Debug.shadow.querySelector('#' + this.id); draggable(this.elem, { handles: this.elem.querySelectorAll('li:first-child'), @@ -357,7 +367,7 @@ class Bar { close() { - document.getElementById('tracy-debug').style.display = 'none'; + Debug.host.style.display = 'none'; } @@ -379,7 +389,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(); } @@ -396,15 +406,26 @@ class Debug { static init(content) { Debug.bar = new Bar; Debug.panels = {}; + + // Shadow DOM for CSS isolation + let host = document.createElement('tracy-bar'); + Debug.host = host; + let shadow = host.attachShadow({ mode: 'open' }); + Debug.shadow = shadow; + Tracy.adoptStyleSheets(shadow, ['shared', 'bar']); + + // #tracy-debug wrapper is kept for backward compatibility of third-party panel CSS Debug.layer = document.createElement('tracy-div'); Debug.layer.setAttribute('id', 'tracy-debug'); Debug.layer.innerHTML = content; - (document.body || document.documentElement).appendChild(Debug.layer); + shadow.appendChild(Debug.layer); + + (document.body || document.documentElement).appendChild(host); evalScripts(Debug.layer); - Debug.layer.style.display = 'block'; + host.style.display = 'block'; Debug.bar.init(); - Debug.layer.querySelectorAll('.tracy-panel').forEach((panel) => { + Debug.shadow.querySelectorAll('.tracy-panel').forEach((panel) => { Debug.panels[panel.id] = new Panel(panel.id); Debug.panels[panel.id].restorePosition(); }); @@ -447,7 +468,7 @@ class Debug { Debug.bar.elem.insertAdjacentHTML('beforeend', content.bar); let ajaxBar = Debug.bar.elem.querySelector('.tracy-row:last-child'); - Debug.layer.querySelectorAll('.tracy-panel').forEach((panel) => { + Debug.shadow.querySelectorAll('.tracy-panel').forEach((panel) => { if (!Debug.panels[panel.id]) { Debug.panels[panel.id] = new Panel(panel.id); Debug.panels[panel.id].restorePosition(); @@ -492,19 +513,22 @@ class Debug { oldOpen.apply(this, arguments); if (getOption('AutoRefresh') && new URL(arguments[1], location.origin).host === location.host) { - let reqId = Tracy.getAjaxHeader(); - this.setRequestHeader('X-Tracy-Ajax', reqId); - this.addEventListener('load', function () { - if (this.getAllResponseHeaders().match(/^X-Tracy-Ajax: 1/mi)) { - Debug.loadScript(baseUrl + '_tracy_bar=content-ajax.' + reqId + '&XDEBUG_SESSION_STOP=1&v=' + Math.random()); - } - }); + this.tracyReqId = Tracy.getAjaxHeader(); + this.setRequestHeader('X-Tracy-Ajax', this.tracyReqId); + if (!this.tracyLoadListener) { // open() may be called repeatedly on the same instance + this.tracyLoadListener = true; + this.addEventListener('load', function () { + if (this.getAllResponseHeaders().match(/^X-Tracy-Ajax: 1/mi)) { + Debug.loadScript(baseUrl + '_tracy_bar=content-ajax.' + this.tracyReqId + '&XDEBUG_SESSION_STOP=1&v=' + Math.random()); + } + }); + } } }; 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) { @@ -541,6 +565,7 @@ function evalScripts(elem) { let dolly = document.createElement('script'); dolly.textContent = script.textContent; (document.body || document.documentElement).appendChild(dolly); + dolly.remove(); script.tracyEvaluated = true; } }); 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..ac625a4c5 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; @@ -370,7 +370,7 @@ public static function highlightFile( ? CodeHighlighter::highlightPhp($source, $line, $column) : '
' . CodeHighlighter::highlightLine(htmlspecialchars($source, ENT_IGNORE, 'UTF-8'), $line, $column) . '
'; - if ($editor = Helpers::editorUri($file, $line)) { + if ($editor = Helpers::editorUri($file, line: $line, column: $column)) { $source = substr_replace($source, ' title="Ctrl-Click to open in editor" data-tracy-href="' . Helpers::escapeHtml($editor) . '"', 4, 0); } @@ -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( @@ -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]); } 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/bluescreen.css b/src/Tracy/BlueScreen/assets/bluescreen.css index dd80a54a2..c3eabfdc5 100644 --- a/src/Tracy/BlueScreen/assets/bluescreen.css +++ b/src/Tracy/BlueScreen/assets/bluescreen.css @@ -2,6 +2,13 @@ * This file is part of the Tracy (https://tracy.nette.org) */ +@layer tracy-components { + +/* shadow DOM host */ +:host { + display: contents; +} + html.tracy-bs-visible, html.tracy-bs-visible body { display: block; @@ -9,7 +16,7 @@ html.tracy-bs-visible body { position: static; } -#tracy-bs { +.tracy-bs { font: 9pt/1.5 Verdana, sans-serif; background: white; color: #333; @@ -19,404 +26,406 @@ html.tracy-bs-visible body { top: 0; width: 100%; text-align: left; -} -#tracy-bs a { - text-decoration: none; - color: #328ADC; - padding: 0 4px; - margin: 0 -4px; -} + a { + text-decoration: none; + color: #328ADC; + padding: 0 4px; + margin: 0 -4px; + } -#tracy-bs a + a { - margin-left: 0; -} + a + a { + margin-left: 0; + } -#tracy-bs a:hover, -#tracy-bs a:focus { - color: #085AA3; -} + a:hover, + a:focus { + color: #085AA3; + } -#tracy-bs-toggle { - position: absolute; - right: .5em; - top: .5em; - text-decoration: none; - background: #CD1818; - color: white !important; - padding: 3px; -} + .tracy-bs-toggle { + position: absolute; + right: .5em; + top: .5em; + text-decoration: none; + background: #CD1818; + color: white !important; + padding: 3px; + } -#tracy-bs-toggle.tracy-collapsed { - position: fixed; -} + .tracy-bs-toggle.tracy-collapsed { + position: fixed; + } -.tracy-bs-main { - display: flex; - flex-direction: column; - padding-bottom: 80vh; -} + .tracy-bs-main { + display: flex; + flex-direction: column; + padding-bottom: 80vh; + } -.tracy-bs-main.tracy-collapsed { - display: none; -} + .tracy-bs-main.tracy-collapsed { + display: none; + } -#tracy-bs :where(:is( - h1, h2, h3, h4, h5, h6, - p, - ol, ul, dl, - pre, table, hr, - .tracy-section-panel, - .tracy-pane -):not(:first-child)) { - margin-top: var(--tracy-space); -} + :where(:is( + h1, h2, h3, h4, h5, h6, + p, + ol, ul, dl, + pre, table, hr, + .tracy-section-panel, + .tracy-pane + ):not(:first-child)) { + margin-top: var(--tracy-space); + } -#tracy-bs h1 { - font-size: 15pt; - font-weight: normal; - text-shadow: 1px 1px 2px rgba(0, 0, 0, .3); -} + h1 { + font-size: 15pt; + font-weight: normal; + text-shadow: 1px 1px 2px rgba(0, 0, 0, .3); + } -#tracy-bs h1 span { - white-space: pre-wrap; -} + h1 span { + white-space: pre-wrap; + } -#tracy-bs h2 { - font-size: 14pt; - font-weight: normal; -} + h2 { + font-size: 14pt; + font-weight: normal; + } -#tracy-bs h3 { - font-size: 10pt; - font-weight: bold; -} + h3 { + font-size: 10pt; + font-weight: bold; + } -#tracy-bs pre, -#tracy-bs code, -#tracy-bs table { - font: 9pt/1.5 Consolas, monospace !important; -} + pre, + code, + table { + font: 9pt/1.5 Consolas, monospace !important; + } -#tracy-bs pre, -#tracy-bs table { - background: #FDF5CE; - padding: .4em .7em; - border: 2px solid #ffffffa6; - box-shadow: 1px 2px 6px #00000005; - overflow: auto; -} + pre, + table { + background: #FDF5CE; + padding: .4em .7em; + border: 2px solid #ffffffa6; + box-shadow: 1px 2px 6px #00000005; + overflow: auto; + } -#tracy-bs table pre { - padding: 0; - margin: 0; - border: none; - box-shadow: none; -} + table pre { + padding: 0; + margin: 0; + border: none; + box-shadow: none; + } -#tracy-bs table { - border-collapse: collapse; - width: 100%; -} + table { + border-collapse: collapse; + width: 100%; + } -#tracy-bs td, -#tracy-bs th { - vertical-align: top; - text-align: left; - padding: 2px 6px; - border: 1px solid #e6dfbf; -} + td, + th { + vertical-align: top; + text-align: left; + padding: 2px 6px; + border: 1px solid #e6dfbf; + } -#tracy-bs th { - font-weight: bold; -} + th { + font-weight: bold; + } -#tracy-bs tr > :first-child { - width: 20%; -} + tr > :first-child { + width: 20%; + } -#tracy-bs tr:nth-child(2n), -#tracy-bs tr:nth-child(2n) pre { - background-color: #F7F0CB; -} + tr:nth-child(2n), + tr:nth-child(2n) pre { + background-color: #F7F0CB; + } -#tracy-bs .tracy-footer--sticky { - position: fixed; - width: 100%; - bottom: 0; -} + .tracy-footer--sticky { + position: fixed; + width: 100%; + bottom: 0; + } -#tracy-bs footer ul { - font-size: 7pt; - padding: var(--tracy-space); - margin: var(--tracy-space) 0 0; - color: #777; - background: #F6F5F3; - border-top: 1px solid #DDD; - list-style: none; -} + footer ul { + font-size: 7pt; + padding: var(--tracy-space); + margin: var(--tracy-space) 0 0; + color: #777; + background: #F6F5F3; + border-top: 1px solid #DDD; + list-style: none; + } -#tracy-bs .tracy-footer-logo { - position: relative; -} + .tracy-footer-logo { + position: relative; + } -#tracy-bs .tracy-footer-logo a { - position: absolute; - bottom: 0; - right: 0; - width: 100px; - height: 50px; - background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFoAAAAUBAMAAAD/1DctAAAAMFBMVEWupZzj39rEvbTy8O3X0sz9/PvGwLu8tavQysHq6OS0rKP5+Pbd2dT29fPMxbzPx8DKErMJAAAACXBIWXMAAAsTAAALEwEAmpwYAAACGUlEQVQoFX3TQWgTQRQA0MWLIJJDYehBTykhG5ERTx56K1u8eEhCYtomE7x5L4iLh0ViF7egewuFFqSIYE6hIHsIYQ6CQSg9CDKn4QsNCRlB59C74J/ZNHW1+An5+bOPyf6/s46oz2P+A0yIeZZ2ieEHi6TOnLKTxvWq+b52mxlVO3xnM1s7xLX1504XQH65OnW2dBqn7cCkYsFsfYsWpyY/2salmFTpEyzeR8zosYqMdiPDXdyU52K1wgEa/SjGpdEwUAxqvRfckQCDOyFearsEHe2grvkh/cFAHKvdtI3lcVceKQIOFpv+FOZaNPQBwJZLPp+hfrvT5JZXaUFsR8zqQc9qSgAharkfS5M/5F6nGJJAtXq/eLr3ucZpHccSxOOIPaQhtHohpCH2Xu6rLmQ0djnr4/+J3C6v+AW8/XWYxwYNdlhWj/P5fPSTQwVr0T9lGxdaBCqErNZaqYnEwbkjEB3NasGF3lPdrHa1nnxNOMgj0+neePUPjd2v/qVvUv29ifvc19huQ48qwXShy/9o8o3OSk0cs37mOFd0Ydgvsf/oZEnPVtggfd66lORn9mDyyzXU13SRtH2L6aR5T/snGAcZPfAXz5J1YlJWBEuxdMYqQecpBrlM49xAbmqyHA+xlA1FxBtqT2xmJoNXZlIt74ZBLeJ9ZGDqByNI7p543idzJ23vXEv7IgnsxiS+eNtwNbFdLq7+Bi4wQ0I4SVb9AAAAAElFTkSuQmCC') no-repeat; - opacity: .6; - padding: 0; - margin: 0; -} + .tracy-footer-logo a { + position: absolute; + bottom: 0; + right: 0; + width: 100px; + height: 50px; + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFoAAAAUBAMAAAD/1DctAAAAMFBMVEWupZzj39rEvbTy8O3X0sz9/PvGwLu8tavQysHq6OS0rKP5+Pbd2dT29fPMxbzPx8DKErMJAAAACXBIWXMAAAsTAAALEwEAmpwYAAACGUlEQVQoFX3TQWgTQRQA0MWLIJJDYehBTykhG5ERTx56K1u8eEhCYtomE7x5L4iLh0ViF7egewuFFqSIYE6hIHsIYQ6CQSg9CDKn4QsNCRlB59C74J/ZNHW1+An5+bOPyf6/s46oz2P+A0yIeZZ2ieEHi6TOnLKTxvWq+b52mxlVO3xnM1s7xLX1504XQH65OnW2dBqn7cCkYsFsfYsWpyY/2salmFTpEyzeR8zosYqMdiPDXdyU52K1wgEa/SjGpdEwUAxqvRfckQCDOyFearsEHe2grvkh/cFAHKvdtI3lcVceKQIOFpv+FOZaNPQBwJZLPp+hfrvT5JZXaUFsR8zqQc9qSgAharkfS5M/5F6nGJJAtXq/eLr3ucZpHccSxOOIPaQhtHohpCH2Xu6rLmQ0djnr4/+J3C6v+AW8/XWYxwYNdlhWj/P5fPSTQwVr0T9lGxdaBCqErNZaqYnEwbkjEB3NasGF3lPdrHa1nnxNOMgj0+neePUPjd2v/qVvUv29ifvc19huQ48qwXShy/9o8o3OSk0cs37mOFd0Ydgvsf/oZEnPVtggfd66lORn9mDyyzXU13SRtH2L6aR5T/snGAcZPfAXz5J1YlJWBEuxdMYqQecpBrlM49xAbmqyHA+xlA1FxBtqT2xmJoNXZlIt74ZBLeJ9ZGDqByNI7p543idzJ23vXEv7IgnsxiS+eNtwNbFdLq7+Bi4wQ0I4SVb9AAAAAElFTkSuQmCC') no-repeat; + opacity: .6; + padding: 0; + margin: 0; + } -#tracy-bs .tracy-footer-logo a:hover, -#tracy-bs .tracy-footer-logo a:focus { - opacity: 1; - transition: opacity 0.1s; -} + .tracy-footer-logo a:hover, + .tracy-footer-logo a:focus { + opacity: 1; + transition: opacity 0.1s; + } -#tracy-bs .tracy-section { - padding: var(--tracy-space); -} + .tracy-section { + padding: var(--tracy-space); + } -#tracy-bs .tracy-section-panel { - background: #5040200E; - padding: var(--tracy-space); - border-radius: 8px; - box-shadow: inset 1px 1px 0px 0 #00000005; - overflow: hidden; -} + .tracy-section-panel { + background: #5040200E; + padding: var(--tracy-space); + border-radius: 8px; + box-shadow: inset 1px 1px 0px 0 #00000005; + overflow: hidden; + } -#tracy-bs .outer, /* deprecated */ -#tracy-bs .tracy-pane { - overflow: auto; -} + .outer, /* deprecated */ + .tracy-pane { + overflow: auto; + } -#tracy-bs.tracy-mac .tracy-pane { - padding-bottom: 12px; -} + &.tracy-mac .tracy-pane { + padding-bottom: 12px; + } -/* header */ -#tracy-bs .tracy-section--error { - background: #CD1818; - color: white; -} + /* header */ + .tracy-section--error { + background: #CD1818; + color: white; + } -#tracy-bs .tracy-section--error p, -#tracy-bs .tracy-section--error h1 { - font-size: 13pt; - color: white; -} + .tracy-section--error p, + .tracy-section--error h1 { + font-size: 13pt; + color: white; + } -#tracy-bs .tracy-section--error::selection, -#tracy-bs .tracy-section--error ::selection { - color: black !important; - background: #FDF5CE !important; -} + .tracy-section--error::selection, + .tracy-section--error ::selection { + color: black !important; + background: #FDF5CE !important; + } -#tracy-bs .tracy-section--error h1 a { - color: #ffefa1 !important; -} + .tracy-section--error h1 a { + color: #ffefa1 !important; + } -#tracy-bs .tracy-section--error span span { - font-size: 80%; - color: rgba(255, 255, 255, 0.5); - text-shadow: none; -} + .tracy-section--error span span { + font-size: 80%; + color: rgba(255, 255, 255, 0.5); + text-shadow: none; + } -#tracy-bs .tracy-section--error a.tracy-action { - color: white !important; - opacity: 0; - font-size: .7em; - border-bottom: none !important; -} + .tracy-section--error a.tracy-action { + color: white !important; + opacity: 0; + font-size: .7em; + border-bottom: none !important; + } -#tracy-bs .tracy-section--error:hover a.tracy-action { - opacity: .6; -} + .tracy-section--error:hover a.tracy-action { + opacity: .6; + } -#tracy-bs .tracy-section--error a.tracy-action:hover { - opacity: 1; -} + .tracy-section--error a.tracy-action:hover { + opacity: 1; + } -#tracy-bs .tracy-section--error i { - color: #ffefa1; - font-style: normal; -} + .tracy-section--error i { + color: #ffefa1; + font-style: normal; + } -#tracy-bs .tracy-section--error:has(.tracy-caused) { - border-radius: 0 0 0 8px; - overflow: hidden; -} + .tracy-section--error:has(.tracy-caused) { + border-radius: 0 0 0 8px; + overflow: hidden; + } -#tracy-bs .tracy-caused { - margin: var(--tracy-space) calc(-1 * var(--tracy-space)) calc(-1 * var(--tracy-space)); - padding: .3em var(--tracy-space); - background: #df8075; - white-space: nowrap; -} + .tracy-caused { + margin: var(--tracy-space) calc(-1 * var(--tracy-space)) calc(-1 * var(--tracy-space)); + padding: .3em var(--tracy-space); + background: #df8075; + white-space: nowrap; + } -#tracy-bs .tracy-caused a { - color: white; -} + .tracy-caused a { + color: white; + } -/* source code */ -#tracy-bs pre.tracy-code > div { - min-width: fit-content; - white-space: pre; -} + /* source code */ + pre.tracy-code > div { + min-width: fit-content; + white-space: pre; + } -#tracy-bs .tracy-code-comment { - color: rgba(0, 0, 0, 0.5); - font-style: italic; -} + .tracy-code-comment { + color: rgba(0, 0, 0, 0.5); + font-style: italic; + } -#tracy-bs .tracy-code-keyword { - color: #D24; - font-weight: bold; -} + .tracy-code-keyword { + color: #D24; + font-weight: bold; + } -#tracy-bs .tracy-code-var { - font-weight: bold; -} + .tracy-code-var { + font-weight: bold; + } -#tracy-bs .tracy-line-highlight { - background: #CD1818; - color: white; - font-weight: bold; - font-style: normal; - display: block; - padding: 0 1ch; - margin: 0 -1ch -1lh; -} + .tracy-line-highlight { + background: #CD1818; + color: white; + font-weight: bold; + font-style: normal; + display: block; + padding: 0 1ch; + margin: 0 -1ch -1lh; + } -#tracy-bs .tracy-column-highlight { - display: inline-block; - backdrop-filter: grayscale(1); - margin: 0 -1px; - padding: 0 1px; -} + .tracy-column-highlight { + display: inline-block; + backdrop-filter: grayscale(1); + margin: 0 -1px; + padding: 0 1px; + } -#tracy-bs .tracy-line { - color: #9F9C7F; - font-weight: normal; - font-style: normal; -} + .tracy-line { + color: #9F9C7F; + font-weight: normal; + font-style: normal; + } -#tracy-bs a.tracy-editor { - color: inherit; - border-bottom: 1px dotted rgba(0, 0, 0, .3); - border-radius: 3px; -} + a.tracy-editor { + color: inherit; + border-bottom: 1px dotted rgba(0, 0, 0, .3); + border-radius: 3px; + } -#tracy-bs a.tracy-editor:hover { - background: #0001; -} + a.tracy-editor:hover { + background: #0001; + } -#tracy-bs span[data-tracy-href] { - border-bottom: 1px dotted rgba(0, 0, 0, .3); -} + span[data-tracy-href] { + border-bottom: 1px dotted rgba(0, 0, 0, .3); + } -#tracy-bs .tracy-dump-whitespace { - color: #0003; -} + .tracy-dump-whitespace { + color: #0003; + } -#tracy-bs .tracy-callstack { - display: grid; - overflow: auto; - grid-template-columns: max-content 1fr; - row-gap: calc(.5 * var(--tracy-space)); -} + .tracy-callstack { + display: grid; + overflow: auto; + grid-template-columns: max-content 1fr; + row-gap: calc(.5 * var(--tracy-space)); + } -#tracy-bs .tracy-callstack-file { - text-align: right; - padding-right: var(--tracy-space); - white-space: nowrap; -} + .tracy-callstack-file { + text-align: right; + padding-right: var(--tracy-space); + white-space: nowrap; + } -#tracy-bs .tracy-callstack-callee { - white-space: nowrap; -} + .tracy-callstack-callee { + white-space: nowrap; + } -#tracy-bs .tracy-callstack-additional { - grid-column-start: 1; - grid-column-end: 3; -} + .tracy-callstack-additional { + grid-column-start: 1; + grid-column-end: 3; + } -#tracy-bs .tracy-callstack-args tr:first-child > * { - position: relative; -} + .tracy-callstack-args tr:first-child > * { + position: relative; + } -#tracy-bs .tracy-callstack-args tr:first-child td:before { - position: absolute; - right: .3em; - content: 'may not be true'; - opacity: .4; -} + .tracy-callstack-args tr:first-child td:before { + position: absolute; + right: .3em; + content: 'may not be true'; + opacity: .4; + } -#tracy-bs .tracy-panel-fadein { - animation: tracy-panel-fadein .12s ease; -} + .tracy-panel-fadein { + animation: tracy-panel-fadein .12s ease; + } -@keyframes tracy-panel-fadein { - 0% { - opacity: 0; + @keyframes tracy-panel-fadein { + 0% { + opacity: 0; + } } -} -#tracy-bs .tracy-section--causedby { - flex-direction: column; - padding: 0; -} + .tracy-section--causedby { + flex-direction: column; + padding: 0; + } -#tracy-bs .tracy-section--causedby:not(.tracy-collapsed) { - display: flex; -} + .tracy-section--causedby:not(.tracy-collapsed) { + display: flex; + } -#tracy-bs .tracy-section--causedby .tracy-section--error { - background: #cd1818a6; -} + .tracy-section--causedby .tracy-section--error { + background: #cd1818a6; + } -#tracy-bs .tracy-section--error + .tracy-section--stack { - margin-top: calc(1.5 * var(--tracy-space)); -} + .tracy-section--error + .tracy-section--stack { + margin-top: calc(1.5 * var(--tracy-space)); + } -/* tabs */ -#tracy-bs .tracy-tab-bar { - display: flex; - list-style: none; - padding-left: 0; - margin: 0; - width: 100%; - font-size: 110%; - column-gap: var(--tracy-space); -} + /* tabs */ + .tracy-tab-bar { + display: flex; + list-style: none; + padding-left: 0; + margin: 0; + width: 100%; + font-size: 110%; + column-gap: var(--tracy-space); + } -#tracy-bs .tracy-tab-bar a { - display: block; - padding: calc(.5 * var(--tracy-space)) var(--tracy-space); - margin: 0; - height: 100%; - box-sizing: border-box; - border-radius: 5px 5px 0 0; - text-decoration: none; - transition: all 0.1s; -} + .tracy-tab-bar a { + display: block; + padding: calc(.5 * var(--tracy-space)) var(--tracy-space); + margin: 0; + height: 100%; + box-sizing: border-box; + border-radius: 5px 5px 0 0; + text-decoration: none; + transition: all 0.1s; + } -#tracy-bs .tracy-tab-bar > .tracy-active a { - background: white; + .tracy-tab-bar > .tracy-active a { + background: white; + } + + .tracy-tab-panel { + border-top: 2px solid white; + padding-top: var(--tracy-space); + overflow: auto; + } } -#tracy-bs .tracy-tab-panel { - border-top: 2px solid white; - padding-top: var(--tracy-space); - overflow: auto; } diff --git a/src/Tracy/BlueScreen/assets/bluescreen.js b/src/Tracy/BlueScreen/assets/bluescreen.js index c8983b207..1de63faaa 100644 --- a/src/Tracy/BlueScreen/assets/bluescreen.js +++ b/src/Tracy/BlueScreen/assets/bluescreen.js @@ -6,30 +6,51 @@ class BlueScreen { static init(ajax) { BlueScreen.globalInit(); - let blueScreen = document.getElementById('tracy-bs'); + let blueScreen = document.querySelector('.tracy-bs'); + + // Shadow DOM for CSS isolation + let host = document.createElement('tracy-bs'); + let shadow = host.attachShadow({ mode: 'open' }); + BlueScreen.shadow = shadow; + BlueScreen.host = host; + + if (ajax) { // injected into a host page + Tracy.adoptStyleSheets(shadow, ['shared', 'bluescreen']); + // all bluescreen rules are scoped to .tracy-bs which lives in the shadow root, + // document-level adoption only activates the html.tracy-bs-visible rules + Tracy.adoptStyleSheets(document, ['bluescreen']); + } else { // standalone error page, styles are in document.head so the page works without JavaScript + document.querySelectorAll('style.tracy-debug').forEach((s) => { + shadow.appendChild(s.cloneNode(true)); + }); + } + + shadow.appendChild(blueScreen); + document.body.appendChild(host); document.documentElement.classList.add('tracy-bs-visible'); - if (navigator.platform.indexOf('Mac') > -1) { + if (navigator.userAgent.includes('Mac')) { blueScreen.classList.add('tracy-mac'); } blueScreen.addEventListener('tracy-toggle', (e) => { - if (e.target.matches('#tracy-bs-toggle')) { // blue screen toggle + let target = Tracy.retarget(e); + if (target.matches('.tracy-bs-toggle')) { // blue screen toggle document.documentElement.classList.toggle('tracy-bs-visible', !e.detail.collapsed); - } else if (!e.target.matches('.tracy-dump *') && e.detail.originalEvent) { // panel toggle + } else if (!target.matches('.tracy-dump *') && e.detail.originalEvent) { // panel toggle e.detail.relatedTarget.classList.toggle('tracy-panel-fadein', !e.detail.collapsed); } }); if (!ajax) { - document.body.appendChild(blueScreen); - let id = location.href + document.querySelector('.tracy-section--error').textContent; + let id = location.href + shadow.querySelector('.tracy-section--error').textContent; Tracy.Toggle.persist(blueScreen, sessionStorage.getItem('tracy-toggles-bskey') === id); sessionStorage.setItem('tracy-toggles-bskey', id); } - (new ResizeObserver(stickyFooter)).observe(blueScreen); + Tracy.Dumper.init(shadow); + (new ResizeObserver(() => stickyFooter(shadow))).observe(blueScreen); if (document.documentElement.classList.contains('tracy-bs-visible')) { blueScreen.scrollIntoView(); @@ -40,34 +61,38 @@ class BlueScreen { static globalInit() { // enables toggling via ESC document.addEventListener('keyup', (e) => { - if (e.keyCode === 27 && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) { // ESC - Tracy.Toggle.toggle(document.getElementById('tracy-bs-toggle')); + if (e.key === 'Escape' && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) { + let toggle = BlueScreen.shadow && BlueScreen.shadow.querySelector('.tracy-bs-toggle'); + if (toggle) { + Tracy.Toggle.toggle(toggle); + } } }); Tracy.TableSort.init(); Tracy.Tabs.init(); - window.addEventListener('scroll', stickyFooter); + window.addEventListener('scroll', () => stickyFooter(BlueScreen.shadow)); BlueScreen.globalInit = function () {}; } static loadAjax(content) { - let ajaxBs = document.getElementById('tracy-bs'); - if (ajaxBs) { - ajaxBs.remove(); + let host = document.querySelector('tracy-bs'); + if (host) { + host.remove(); } document.body.insertAdjacentHTML('beforeend', content); - ajaxBs = document.getElementById('tracy-bs'); - Tracy.Dumper.init(ajaxBs); BlueScreen.init(true); } } -function stickyFooter() { - let footer = document.querySelector('#tracy-bs footer'); +function stickyFooter(root) { + let footer = root && root.querySelector('footer'); + if (!footer) { + return; + } footer.classList.toggle('tracy-footer--sticky', false); // to measure footer.offsetTop footer.classList.toggle('tracy-footer--sticky', footer.offsetHeight + footer.offsetTop - window.innerHeight - document.documentElement.scrollTop < 0); } diff --git a/src/Tracy/BlueScreen/assets/content.latte b/src/Tracy/BlueScreen/assets/content.latte index 6b37a21b7..b617acb96 100644 --- a/src/Tracy/BlueScreen/assets/content.latte +++ b/src/Tracy/BlueScreen/assets/content.latte @@ -19,8 +19,8 @@ {varType Fiber[] $fibers} {* *} - -  +
+ 
{do $ex = $exception} @@ -71,4 +71,4 @@
- +
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/content.phtml b/src/Tracy/BlueScreen/dist/content.phtml index 7d04ce72f..75f2fca22 100644 --- a/src/Tracy/BlueScreen/dist/content.phtml +++ b/src/Tracy/BlueScreen/dist/content.phtml @@ -19,8 +19,8 @@ use Tracy\Helpers; /** @var mixed[][] $obStatus */ /** @var Generator[] $generators */ /** @var Fiber[] $fibers */ -echo ' -  +echo '
+ 
'; @@ -109,5 +109,5 @@ echo ' - +
'; 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..88c176a76 100644 --- a/src/Tracy/Debugger/Debugger.php +++ b/src/Tracy/Debugger/Debugger.php @@ -17,7 +17,7 @@ */ class Debugger { - public const Version = '2.12.0'; + public const Version = '3.0-dev'; /** server modes for Debugger::enable() */ public const @@ -27,19 +27,19 @@ class Debugger public const CookieSecret = 'tracy-debug'; - /** @deprecated use Debugger::Version */ + #[\Deprecated('use Debugger::Version')] public const VERSION = self::Version; - /** @deprecated use Debugger::Development */ + #[\Deprecated('use Debugger::Development')] public const DEVELOPMENT = self::Development; - /** @deprecated use Debugger::Production */ + #[\Deprecated('use Debugger::Production')] public const PRODUCTION = self::Production; - /** @deprecated use Debugger::Detect */ + #[\Deprecated('use Debugger::Detect')] public const DETECT = self::Detect; - /** @deprecated use Debugger::CookieSecret */ + #[\Deprecated('use Debugger::CookieSecret')] public const COOKIE_SECRET = self::CookieSecret; /** in production mode is suppressed any debugging output */ @@ -93,7 +93,7 @@ class Debugger /** theme for dump() */ public static string $dumpTheme = 'light'; - /** @deprecated */ + #[\Deprecated] public static $maxLen; /********************* logging ****************d*g**/ @@ -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( @@ -484,33 +484,22 @@ 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, - ]; + $options = self::dumpOptions(); return Helpers::isCli() - ? Dumper::toText($var) + ? Dumper::toText($var, $options) : Helpers::capture(fn() => Dumper::dump($var, $options)); } elseif (!self::$productionMode) { $html = Helpers::isHtmlMode(); - echo $html ? '' : ''; - Dumper::dump($var, [ - Dumper::DEPTH => self::$maxDepth, - Dumper::TRUNCATE => self::$maxLength, - Dumper::ITEMS => self::$maxItems, + echo $html ? '' : ''; + Dumper::dump($var, self::dumpOptions() + [ Dumper::LOCATION => self::$showLocation, Dumper::THEME => self::$dumpTheme, - Dumper::KEYS_TO_HIDE => self::$keysToHide, ]); - echo $html ? '' : ''; + 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())); } } @@ -549,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. */ diff --git a/src/Tracy/Debugger/DeferredContent.php b/src/Tracy/Debugger/DeferredContent.php index 2dfe9f688..d603fdfca 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'); @@ -119,36 +122,40 @@ public function sendAssets(): bool private function buildJsCss(): string { - $css = array_map(file_get_contents(...), array_merge([ + $sharedCss = array_map(file_get_contents(...), array_merge([ __DIR__ . '/../assets/reset.css', - __DIR__ . '/../Bar/assets/bar.css', __DIR__ . '/../assets/toggle.css', __DIR__ . '/../assets/table-sort.css', __DIR__ . '/../assets/tabs.css', __DIR__ . '/../Dumper/assets/dumper-light.css', __DIR__ . '/../Dumper/assets/dumper-dark.css', - __DIR__ . '/../BlueScreen/assets/bluescreen.css', ], Debugger::$customCssFiles)); + $barCss = file_get_contents(__DIR__ . '/../Bar/assets/bar.css') ?: throw new \RuntimeException('Cannot read bar.css'); + $bsCss = file_get_contents(__DIR__ . '/../BlueScreen/assets/bluescreen.css') ?: throw new \RuntimeException('Cannot read bluescreen.css'); $js1 = array_map(fn($file) => '(function() {' . file_get_contents($file) . '})();', [ + __DIR__ . '/../assets/helpers.js', // must run first, defines the Tracy.css registry helpers __DIR__ . '/../Bar/assets/bar.js', __DIR__ . '/../assets/toggle.js', __DIR__ . '/../assets/table-sort.js', __DIR__ . '/../assets/tabs.js', - __DIR__ . '/../assets/helpers.js', __DIR__ . '/../Dumper/assets/dumper.js', __DIR__ . '/../BlueScreen/assets/bluescreen.js', ]); $js2 = array_map(file_get_contents(...), Debugger::$customJsFiles); + // CSS is exposed via the Tracy.css registry and applied through adoptedStyleSheets, + // nothing is ever injected into the host page's document.head $str = "'use strict'; (function(){ - var el = document.createElement('style'); - el.setAttribute('nonce', document.currentScript.getAttribute('nonce') || document.currentScript.nonce); - el.className='tracy-debug'; - el.textContent=" . Helpers::jsonEncode(Helpers::minifyCss(implode('', $css))) . "; - document.head.appendChild(el);}) -();\n" . implode('', $js1) . implode('', $js2); + var Tracy = window.Tracy = window.Tracy || {}; + Tracy.css = Object.assign(Tracy.css || {}, { + shared: " . json_encode(Helpers::minifyCss(implode('', $sharedCss))) . ', + bar: ' . json_encode(Helpers::minifyCss($barCss)) . ', + bluescreen: ' . json_encode(Helpers::minifyCss($bsCss)) . ' + });}) +(); +' . implode('', $js1) . implode('', $js2); return $str; } @@ -157,7 +164,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/Debugger/DevelopmentStrategy.php b/src/Tracy/Debugger/DevelopmentStrategy.php index 8c3a0a365..fcadd0fa1 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; } @@ -62,11 +66,17 @@ private function renderExceptionCli(\Throwable $exception): void header("X-Tracy-Error-Log: $logFile", replace: false); } + if (Helpers::isAgent() && !Helpers::isCli() && !Helpers::isHtmlMode()) { + // non-HTML HTTP response for an agent carries the markdown report directly in the body + echo $this->blueScreen->renderAgent($exception) . ($logFile ? "\n(stored in $logFile)\n" : ''); + return; + } + if (Helpers::detectColors() && @is_file($exception->getFile())) { 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))); } diff --git a/src/Tracy/Debugger/ProductionStrategy.php b/src/Tracy/Debugger/ProductionStrategy.php index ed760e00a..71410cf70 100644 --- a/src/Tracy/Debugger/ProductionStrategy.php +++ b/src/Tracy/Debugger/ProductionStrategy.php @@ -63,7 +63,7 @@ public function handleError( $err = 'PHP ' . Helpers::errorTypeToString($severity) . ': ' . Helpers::improveError($message) . " in $file:$line"; } - Debugger::tryLog($err, Debugger::ERROR); + Debugger::tryLog($err, Debugger::WARNING); } diff --git a/src/Tracy/Dumper/Describer.php b/src/Tracy/Dumper/Describer.php index d71b67466..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_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, 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; /** @@ -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; @@ -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 + }; } @@ -193,7 +199,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/src/Tracy/Dumper/Dumper.php b/src/Tracy/Dumper/Dumper.php index 5bee48b40..de950c7e0 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 */ @@ -175,17 +179,22 @@ public static function renderAssets(): void $sent = true; $nonceAttr = Helpers::getNonce(attr: true); - $s = (Debugger::$showBar ? '' : file_get_contents(__DIR__ . '/../assets/reset.css')) - . file_get_contents(__DIR__ . '/../assets/toggle.css') + + // class-scoped styles for the light DOM before elements are upgraded; + // reset.css is deliberately not included, it must not leak into the host page + $s = file_get_contents(__DIR__ . '/../assets/toggle.css') . file_get_contents(__DIR__ . '/assets/dumper-light.css') . file_get_contents(__DIR__ . '/assets/dumper-dark.css'); - echo "", str_replace('\n"; + echo "\n"; - if (!Debugger::isEnabled() || !Debugger::$showBar) { - $s = '(function(){' . file_get_contents(__DIR__ . '/../assets/toggle.js') . '})();' - . '(function(){' . file_get_contents(__DIR__ . '/../assets/helpers.js') . '})();' + if (!Debugger::isEnabled() || !Debugger::$showBar) { // otherwise the deferred loader provides the registry & scripts + $css = file_get_contents(__DIR__ . '/../assets/reset.css') . $s; + $s = '(function(){' . file_get_contents(__DIR__ . '/../assets/helpers.js') . '})();' + . '(function(){' . file_get_contents(__DIR__ . '/../assets/toggle.js') . '})();' . '(function(){' . file_get_contents(__DIR__ . '/../Dumper/assets/dumper.js') . '})();'; - echo "", str_replace(['