Skip to content

Commit e717cb6

Browse files
committed
Add support for XDebug path mapping
1 parent c034e0b commit e717cb6

12 files changed

Lines changed: 572 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,40 @@ jobs:
153153
- run: bash ./tests/drupal_test.sh
154154
shell: "bash"
155155

156+
xdebug-tests:
157+
needs:
158+
- 'tests'
159+
160+
name: "XDebug source map tests"
161+
162+
runs-on: 'ubuntu-latest'
163+
164+
continue-on-error: true
165+
166+
steps:
167+
- name: "Checkout code"
168+
uses: actions/checkout@v4
169+
170+
- name: "Install PHP with XDebug"
171+
uses: shivammathur/setup-php@v2
172+
with:
173+
coverage: "xdebug"
174+
php-version: '8.4'
175+
ini-values: memory_limit=-1, xdebug.mode=debug
176+
177+
- name: "Check XDebug version and functions"
178+
run: |
179+
php -v
180+
php -r "echo 'xdebug_set_source_map exists: '.(function_exists('xdebug_set_source_map') ? 'yes' : 'no').PHP_EOL;"
181+
182+
- run: composer install
183+
184+
- name: "Install PHPUnit"
185+
run: vendor/bin/simple-phpunit install
186+
187+
- name: "Run XDebug source map tests"
188+
run: vendor/bin/simple-phpunit tests/Cache/XdebugSourceMapCacheTest.php tests/EnvironmentTest.php --filter "Xdebug"
189+
156190
phpstan:
157191
name: "PHPStan"
158192

CHANGELOG

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# 3.22.2 (2026-XX-XX)
1+
# 3.23.0 (2026-XX-XX)
22

3-
* n/a
3+
* Add support for Xdebug path mapping
44

55
# 3.22.2 (2025-12-14)
66

doc/api.rst

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,20 @@ The following options are available:
152152

153153
``false`` (default): allows templates to use a mix of ``yield`` and ``echo``
154154
calls to allow for a progressive migration.
155-
155+
156156
Switch to ``true`` when possible as this will be the only supported mode in
157157
Twig 4.0.
158158

159+
* ``xdebug_source_map`` *boolean*
160+
161+
Enables generation of Xdebug source map files for debugging Twig templates.
162+
Xdebug 3.5+ can use these maps to set breakpoints directly in ``.twig`` files.
163+
164+
Defaults to the value of ``debug``. Set to ``false`` to disable even when
165+
debugging is enabled. Requires a filesystem-based cache.
166+
167+
See :ref:`debugging-templates` for setup instructions.
168+
159169
Loaders
160170
-------
161171

doc/functions/dump.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ read:
3636

3737
.. tip::
3838

39-
Using a ``pre`` tag is not needed when `XDebug`_ is enabled and
39+
Using a ``pre`` tag is not needed when `Xdebug`_ is enabled and
4040
``html_errors`` is ``on``; as a bonus, the output is also nicer with
41-
XDebug enabled.
41+
Xdebug enabled.
4242

4343
You can debug several variables by passing them as additional arguments:
4444

@@ -62,5 +62,5 @@ Arguments
6262

6363
* ``context``: The context to dump
6464

65-
.. _`XDebug`: https://xdebug.org/docs/display
65+
.. _`Xdebug`: https://xdebug.org/docs/display
6666
.. _`var_dump`: https://www.php.net/var_dump

doc/recipes.rst

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,4 +553,61 @@ safe to avoid any escaping. You can do so by wrapping your expression with a
553553

554554
$safeExpr = new RawFilter(new YourSafeNode());
555555

556+
.. _debugging-templates:
557+
558+
Debugging Twig Templates with Xdebug
559+
------------------------------------
560+
561+
Xdebug 3.5+ supports native path mapping, which allows setting breakpoints
562+
directly in ``.twig`` files and having Xdebug map them to the correct lines
563+
in the compiled PHP files.
564+
565+
When ``debug`` is enabled and a filesystem-based cache is configured, Twig
566+
automatically generates Xdebug source map files in the ``.xdebug`` subdirectory
567+
of the cache directory::
568+
569+
$twig = new \Twig\Environment($loader, [
570+
'debug' => true,
571+
'cache' => '/path/to/cache',
572+
]);
573+
574+
To disable source map generation while keeping debug enabled, set
575+
``xdebug_source_map`` to ``false``.
576+
577+
Configure Xdebug in ``php.ini``::
578+
579+
xdebug.mode=debug
580+
xdebug.start_with_request=yes
581+
xdebug.path_mapping=1
582+
583+
You can then set breakpoints in ``.twig`` files. When debugging, template
584+
variables are available in the ``$context`` array (e.g., ``$context['name']``).
585+
586+
Twig automatically registers source map files with Xdebug.
587+
588+
VSCode-based IDE Setup
589+
~~~~~~~~~~~~~~~~~~~~~~
590+
591+
1. Enable breakpoints in Twig files by adding to ``.vscode/settings.json``:
592+
593+
.. code-block:: json
594+
595+
{
596+
"debug.allowBreakpointsEverywhere": true
597+
}
598+
599+
2. Create a ``.vscode/launch.json`` with a basic configuration in your project:
600+
601+
.. code-block:: json
602+
603+
{
604+
"version": "0.2.0",
605+
"configurations": [{
606+
"name": "Listen for Xdebug",
607+
"type": "php",
608+
"request": "launch",
609+
"port": 9003
610+
}]
611+
}
612+
556613
.. _callback: https://www.php.net/manual/en/function.is-callable.php

src/Cache/ChainCache.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
*
2020
* @author Quentin Devos <quentin@devos.pm>
2121
*/
22-
final class ChainCache implements CacheInterface, RemovableCacheInterface
22+
final class ChainCache implements CacheInterface, DirectoryCacheInterface, RemovableCacheInterface
2323
{
2424
/**
2525
* @param iterable<CacheInterface> $caches The ordered list of caches used to store and fetch cached items
@@ -78,6 +78,18 @@ public function remove(string $name, string $cls): void
7878
}
7979
}
8080

81+
public function getDirectories(): array
82+
{
83+
$directories = [];
84+
foreach ($this->caches as $cache) {
85+
if ($cache instanceof DirectoryCacheInterface) {
86+
$directories []= $cache->getDirectories();
87+
}
88+
}
89+
90+
return array_merge(...$directories);
91+
}
92+
8193
/**
8294
* @return string[]
8395
*/
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Twig.
5+
*
6+
* (c) Fabien Potencier
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Twig\Cache;
13+
14+
/**
15+
* Interface for caches that store files in directories.
16+
*
17+
* @author Fabien Potencier <fabien@symfony.com>
18+
*/
19+
interface DirectoryCacheInterface
20+
{
21+
/**
22+
* @return string[]
23+
*/
24+
public function getDirectories(): array;
25+
}

src/Cache/FilesystemCache.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*
1717
* @author Andrew Tch <andrew@noop.lv>
1818
*/
19-
class FilesystemCache implements CacheInterface, RemovableCacheInterface
19+
class FilesystemCache implements CacheInterface, DirectoryCacheInterface, RemovableCacheInterface
2020
{
2121
public const FORCE_BYTECODE_INVALIDATION = 1;
2222

@@ -29,6 +29,11 @@ public function __construct(string $directory, int $options = 0)
2929
$this->options = $options;
3030
}
3131

32+
public function getDirectories(): array
33+
{
34+
return [$this->directory];
35+
}
36+
3237
public function generateKey(string $name, string $className): string
3338
{
3439
$hash = hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $className);

src/Cache/XdebugSourceMapCache.php

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Twig.
5+
*
6+
* (c) Fabien Potencier
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Twig\Cache;
13+
14+
/**
15+
* Cache decorator that writes Xdebug source map files alongside compiled templates.
16+
*
17+
* Xdebug 3.5+ supports native path mapping via .map files in .xdebug directories.
18+
* This allows setting breakpoints in Twig templates and having Xdebug map them
19+
* to the correct lines in the compiled PHP files.
20+
*
21+
* @author Fabien Potencier <fabien@symfony.com>
22+
*
23+
* @internal
24+
*/
25+
final class XdebugSourceMapCache implements CacheInterface, RemovableCacheInterface
26+
{
27+
private ?string $pendingTemplatePath = null;
28+
private ?array $pendingDebugInfo = null;
29+
30+
public function __construct(
31+
private DirectoryCacheInterface&CacheInterface $cache,
32+
) {
33+
$this->registerExistingSourceMaps();
34+
}
35+
36+
/**
37+
* Registers all existing source map files with Xdebug.
38+
*
39+
* This is called at startup to ensure previously compiled templates
40+
* have their source maps available for debugging.
41+
*/
42+
private function registerExistingSourceMaps(): void
43+
{
44+
foreach ($this->cache->getDirectories() as $directory) {
45+
$mapPath = $directory.'/.xdebug';
46+
if (!is_dir($mapPath)) {
47+
continue;
48+
}
49+
50+
foreach (glob($mapPath.'/*.map') as $mapFile) {
51+
xdebug_set_source_map($mapFile);
52+
}
53+
}
54+
}
55+
56+
public function setSourceMapData(string $templatePath, array $debugInfo): void
57+
{
58+
$this->pendingTemplatePath = $templatePath;
59+
$this->pendingDebugInfo = $debugInfo;
60+
}
61+
62+
public function generateKey(string $name, string $className): string
63+
{
64+
return $this->cache->generateKey($name, $className);
65+
}
66+
67+
public function write(string $key, string $content): void
68+
{
69+
$this->cache->write($key, $content);
70+
71+
if ($this->pendingTemplatePath && $this->pendingDebugInfo) {
72+
try {
73+
$this->writeSourceMap($key, $this->pendingTemplatePath, $this->pendingDebugInfo);
74+
} finally {
75+
$this->pendingTemplatePath = null;
76+
$this->pendingDebugInfo = null;
77+
}
78+
}
79+
}
80+
81+
public function load(string $key): void
82+
{
83+
$this->cache->load($key);
84+
}
85+
86+
public function getTimestamp(string $key): int
87+
{
88+
return $this->cache->getTimestamp($key);
89+
}
90+
91+
public function remove(string $name, string $className): void
92+
{
93+
if ($this->cache instanceof RemovableCacheInterface) {
94+
$this->cache->remove($name, $className);
95+
}
96+
97+
$this->removeSourceMap($this->cache->generateKey($name, $className));
98+
}
99+
100+
private function writeSourceMap(string $cacheKey, string $templatePath, array $debugInfo): void
101+
{
102+
$content = $this->buildMapContent($cacheKey, $templatePath, $debugInfo);
103+
$filename = pathinfo($cacheKey, \PATHINFO_FILENAME).'.map';
104+
105+
foreach ($this->cache->getDirectories() as $directory) {
106+
$mapPath = $directory.'/.xdebug';
107+
108+
if (!is_dir($mapPath)) {
109+
mkdir($mapPath, 0777, true);
110+
}
111+
112+
$mapFile = $mapPath.'/'.$filename;
113+
$tmpFile = tempnam($mapPath, 'map');
114+
if (false !== @file_put_contents($tmpFile, $content) && @rename($tmpFile, $mapFile)) {
115+
@chmod($mapFile, 0666 & ~umask());
116+
xdebug_set_source_map($mapFile);
117+
118+
continue;
119+
}
120+
121+
throw new \RuntimeException(\sprintf('Failed to write Xdebug source map file "%s".', $mapFile));
122+
}
123+
}
124+
125+
private function removeSourceMap(string $cacheKey): void
126+
{
127+
$filename = pathinfo($cacheKey, \PATHINFO_FILENAME).'.map';
128+
129+
foreach ($this->cache->getDirectories() as $directory) {
130+
$mapFile = $directory.'/.xdebug/'.$filename;
131+
if (is_file($mapFile)) {
132+
@unlink($mapFile);
133+
}
134+
}
135+
}
136+
137+
private function buildMapContent(string $cacheKey, string $templatePath, array $debugInfo): string
138+
{
139+
$lines = "# Xdebug source map for Twig template\n";
140+
$lines .= \sprintf("remote_prefix: %s/\n", \dirname($cacheKey));
141+
$lines .= \sprintf("local_prefix: %s/\n\n", \dirname($templatePath));
142+
143+
$compiledLines = array_keys($debugInfo);
144+
for ($i = 0, $count = \count($compiledLines); $i < $count; ++$i) {
145+
$startLine = $compiledLines[$i];
146+
// End line is exclusive, so use next start line minus 1
147+
// Use a reasonable max for last range (Xdebug can't handle PHP_INT_MAX)
148+
$endLine = isset($compiledLines[$i + 1]) ? $compiledLines[$i + 1] - 1 : 999999;
149+
$lines .= \sprintf(
150+
"%s:%d-%d = %s:%d\n",
151+
basename($cacheKey),
152+
$startLine,
153+
$endLine,
154+
basename($templatePath),
155+
$debugInfo[$startLine],
156+
);
157+
}
158+
159+
return $lines;
160+
}
161+
}

0 commit comments

Comments
 (0)