Skip to content

Commit d8b025e

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

10 files changed

Lines changed: 542 additions & 4 deletions

CHANGELOG

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 3.22.2 (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/recipes.rst

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,4 +553,60 @@ 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+
.. note::
587+
588+
XDebug looks for the ``.xdebug`` directory in the grandparent, parent, or
589+
same directory as your entry PHP script. Ensure the cache directory is
590+
located where XDebug can discover it.
591+
592+
**VSCode-based IDE Setup**
593+
594+
1. Enable breakpoints in Twig files by adding to ``.vscode/settings.json``::
595+
596+
{
597+
"debug.allowBreakpointsEverywhere": true
598+
}
599+
600+
2. Create a ``.vscode/launch.json`` with a basic configuration in your project::
601+
602+
{
603+
"version": "0.2.0",
604+
"configurations": [{
605+
"name": "Listen for XDebug",
606+
"type": "php",
607+
"request": "launch",
608+
"port": 9003
609+
}]
610+
}
611+
556612
.. _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 = array_merge($directories, $cache->getDirectories());
87+
}
88+
}
89+
90+
return $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: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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+
}
34+
35+
public function setSourceMapData(string $templatePath, array $debugInfo): void
36+
{
37+
$this->pendingTemplatePath = $templatePath;
38+
$this->pendingDebugInfo = $debugInfo;
39+
}
40+
41+
public function generateKey(string $name, string $className): string
42+
{
43+
return $this->cache->generateKey($name, $className);
44+
}
45+
46+
public function write(string $key, string $content): void
47+
{
48+
$this->cache->write($key, $content);
49+
50+
if ($this->pendingTemplatePath && $this->pendingDebugInfo) {
51+
try {
52+
$this->writeSourceMap($key, $this->pendingTemplatePath, $this->pendingDebugInfo);
53+
} finally {
54+
$this->pendingTemplatePath = null;
55+
$this->pendingDebugInfo = null;
56+
}
57+
}
58+
}
59+
60+
public function load(string $key): void
61+
{
62+
$this->cache->load($key);
63+
}
64+
65+
public function getTimestamp(string $key): int
66+
{
67+
return $this->cache->getTimestamp($key);
68+
}
69+
70+
public function remove(string $name, string $className): void
71+
{
72+
if ($this->cache instanceof RemovableCacheInterface) {
73+
$this->cache->remove($name, $className);
74+
}
75+
76+
$this->removeSourceMap($this->cache->generateKey($name, $className));
77+
}
78+
79+
private function writeSourceMap(string $cacheKey, string $templatePath, array $debugInfo): void
80+
{
81+
$content = $this->buildMapContent($cacheKey, $templatePath, $debugInfo);
82+
$filename = pathinfo($cacheKey, \PATHINFO_FILENAME).'.map';
83+
84+
foreach ($this->cache->getDirectories() as $directory) {
85+
$mapPath = $directory.'/.xdebug';
86+
87+
if (!is_dir($mapPath)) {
88+
mkdir($mapPath, 0777, true);
89+
}
90+
91+
$mapFile = $mapPath.'/'.$filename;
92+
$tmpFile = tempnam($mapPath, 'map');
93+
if (false !== @file_put_contents($tmpFile, $content) && @rename($tmpFile, $mapFile)) {
94+
@chmod($mapFile, 0666 & ~umask());
95+
96+
continue;
97+
}
98+
99+
throw new \RuntimeException(\sprintf('Failed to write XDebug source map file "%s".', $mapFile));
100+
}
101+
}
102+
103+
private function removeSourceMap(string $cacheKey): void
104+
{
105+
$filename = pathinfo($cacheKey, \PATHINFO_FILENAME).'.map';
106+
107+
foreach ($this->cache->getDirectories() as $directory) {
108+
$mapFile = $directory.'/.xdebug/'.$filename;
109+
if (is_file($mapFile)) {
110+
@unlink($mapFile);
111+
}
112+
}
113+
}
114+
115+
private function buildMapContent(string $cacheKey, string $templatePath, array $debugInfo): string
116+
{
117+
$lines = "# XDebug source map for Twig template\n";
118+
$lines .= \sprintf("remote_prefix: %s/\n", \dirname($cacheKey));
119+
$lines .= \sprintf("local_prefix: %s/\n\n", \dirname($templatePath));
120+
121+
$compiledLines = array_keys($debugInfo);
122+
for ($i = 0, $count = \count($compiledLines); $i < $count; ++$i) {
123+
$startLine = $compiledLines[$i];
124+
// End line is exclusive, so use next start line minus 1
125+
// Use a reasonable max for last range (XDebug can't handle PHP_INT_MAX)
126+
$endLine = isset($compiledLines[$i + 1]) ? $compiledLines[$i + 1] - 1 : 999999;
127+
$lines .= \sprintf(
128+
"%s:%d-%d = %s:%d\n",
129+
basename($cacheKey),
130+
$startLine,
131+
$endLine,
132+
basename($templatePath),
133+
$debugInfo[$startLine],
134+
);
135+
}
136+
137+
return $lines;
138+
}
139+
}

src/Environment.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
namespace Twig;
1313

1414
use Twig\Cache\CacheInterface;
15+
use Twig\Cache\DirectoryCacheInterface;
1516
use Twig\Cache\FilesystemCache;
1617
use Twig\Cache\NullCache;
1718
use Twig\Cache\RemovableCacheInterface;
19+
use Twig\Cache\XdebugSourceMapCache;
1820
use Twig\Error\Error;
1921
use Twig\Error\LoaderError;
2022
use Twig\Error\RuntimeError;
@@ -107,6 +109,10 @@ class Environment
107109
* * use_yield: true: forces templates to exclusively use "yield" instead of "echo" (all extensions must be yield ready)
108110
* false (default): allows templates to use a mix of "yield" and "echo" calls to allow for a progressive migration
109111
* Switch to "true" when possible as this will be the only supported mode in Twig 4.0
112+
*
113+
* * xdebug_source_map: Whether to generate XDebug source map files for debugging Twig templates.
114+
* XDebug 3.5+ can use these maps to set breakpoints in Twig templates.
115+
* Defaults to the value of the debug option.
110116
*/
111117
public function __construct(LoaderInterface $loader, array $options = [])
112118
{
@@ -121,6 +127,7 @@ public function __construct(LoaderInterface $loader, array $options = [])
121127
'auto_reload' => null,
122128
'optimizations' => -1,
123129
'use_yield' => false,
130+
'xdebug_source_map' => null,
124131
], $options);
125132

126133
$this->useYield = (bool) $options['use_yield'];
@@ -129,6 +136,7 @@ public function __construct(LoaderInterface $loader, array $options = [])
129136
$this->autoReload = null === $options['auto_reload'] ? $this->debug : (bool) $options['auto_reload'];
130137
$this->strictVariables = (bool) $options['strict_variables'];
131138
$this->setCache($options['cache']);
139+
$this->initializeXdebugSourceMap($options['xdebug_source_map']);
132140
$this->extensionSet = new ExtensionSet();
133141
$this->defaultRuntimeLoader = new FactoryRuntimeLoader([
134142
EscaperRuntime::class => function () { return new EscaperRuntime($this->charset); },
@@ -407,6 +415,9 @@ public function loadTemplate(string $cls, string $name, ?int $index = null): Tem
407415
$source = $this->getLoader()->getSourceContext($name);
408416
$content = $this->compileSource($source);
409417
if (!isset($this->hotCache[$name])) {
418+
if ($this->cache instanceof XdebugSourceMapCache && $source->getPath()) {
419+
$this->cache->setSourceMapData($source->getPath(), $this->compiler->getDebugInfo());
420+
}
410421
$this->cache->write($key, $content);
411422
$this->cache->load($key);
412423
}
@@ -950,4 +961,15 @@ private function updateOptionsHash(): void
950961
$this->useYield ? '1' : '0',
951962
]);
952963
}
964+
965+
private function initializeXdebugSourceMap(?bool $enabled): void
966+
{
967+
$enabled ??= $this->debug;
968+
969+
if (!$enabled || !$this->cache instanceof DirectoryCacheInterface) {
970+
return;
971+
}
972+
973+
$this->cache = new XdebugSourceMapCache($this->cache);
974+
}
953975
}

0 commit comments

Comments
 (0)