diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cad60c963ef..9486e5a61ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,40 @@ jobs: - run: bash ./tests/drupal_test.sh shell: "bash" + xdebug-tests: + needs: + - 'tests' + + name: "XDebug source map tests" + + runs-on: 'ubuntu-latest' + + continue-on-error: true + + steps: + - name: "Checkout code" + uses: actions/checkout@v4 + + - name: "Install PHP with XDebug" + uses: shivammathur/setup-php@v2 + with: + coverage: "xdebug" + php-version: '8.4' + ini-values: memory_limit=-1, xdebug.mode=debug + + - name: "Check XDebug version and functions" + run: | + php -v + php -r "echo 'xdebug_set_source_map exists: '.(function_exists('xdebug_set_source_map') ? 'yes' : 'no').PHP_EOL;" + + - run: composer install + + - name: "Install PHPUnit" + run: vendor/bin/simple-phpunit install + + - name: "Run XDebug source map tests" + run: vendor/bin/simple-phpunit tests/Cache/XdebugSourceMapCacheTest.php tests/EnvironmentTest.php --filter "Xdebug" + phpstan: name: "PHPStan" diff --git a/CHANGELOG b/CHANGELOG index 341de4843e7..32379c05894 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,6 @@ -# 3.22.2 (2026-XX-XX) +# 3.23.0 (2026-XX-XX) - * n/a + * Add support for Xdebug path mapping # 3.22.2 (2025-12-14) diff --git a/doc/api.rst b/doc/api.rst index 24b1baea743..556a474dafc 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -152,10 +152,20 @@ The following options are available: ``false`` (default): allows templates to use a mix of ``yield`` and ``echo`` calls to allow for a progressive migration. - + Switch to ``true`` when possible as this will be the only supported mode in Twig 4.0. +* ``xdebug_source_map`` *boolean* + + Enables generation of Xdebug source map files for debugging Twig templates. + Xdebug 3.5+ can use these maps to set breakpoints directly in ``.twig`` files. + + Defaults to the value of ``debug``. Set to ``false`` to disable even when + debugging is enabled. Requires a filesystem-based cache. + + See :ref:`debugging-templates` for setup instructions. + Loaders ------- diff --git a/doc/functions/dump.rst b/doc/functions/dump.rst index f89a1c4b13a..335dfbbc277 100644 --- a/doc/functions/dump.rst +++ b/doc/functions/dump.rst @@ -36,9 +36,9 @@ read: .. tip:: - Using a ``pre`` tag is not needed when `XDebug`_ is enabled and + Using a ``pre`` tag is not needed when `Xdebug`_ is enabled and ``html_errors`` is ``on``; as a bonus, the output is also nicer with - XDebug enabled. + Xdebug enabled. You can debug several variables by passing them as additional arguments: @@ -62,5 +62,5 @@ Arguments * ``context``: The context to dump -.. _`XDebug`: https://xdebug.org/docs/display +.. _`Xdebug`: https://xdebug.org/docs/display .. _`var_dump`: https://www.php.net/var_dump diff --git a/doc/recipes.rst b/doc/recipes.rst index 2864fdab7e3..a1f5b1129ad 100644 --- a/doc/recipes.rst +++ b/doc/recipes.rst @@ -553,4 +553,61 @@ safe to avoid any escaping. You can do so by wrapping your expression with a $safeExpr = new RawFilter(new YourSafeNode()); +.. _debugging-templates: + +Debugging Twig Templates with Xdebug +------------------------------------ + +Xdebug 3.5+ supports native path mapping, which allows setting breakpoints +directly in ``.twig`` files and having Xdebug map them to the correct lines +in the compiled PHP files. + +When ``debug`` is enabled and a filesystem-based cache is configured, Twig +automatically generates Xdebug source map files in the ``.xdebug`` subdirectory +of the cache directory:: + + $twig = new \Twig\Environment($loader, [ + 'debug' => true, + 'cache' => '/path/to/cache', + ]); + +To disable source map generation while keeping debug enabled, set +``xdebug_source_map`` to ``false``. + +Configure Xdebug in ``php.ini``:: + + xdebug.mode=debug + xdebug.start_with_request=yes + xdebug.path_mapping=1 + +You can then set breakpoints in ``.twig`` files. When debugging, template +variables are available in the ``$context`` array (e.g., ``$context['name']``). + +Twig automatically registers source map files with Xdebug. + +VSCode-based IDE Setup +~~~~~~~~~~~~~~~~~~~~~~ + +1. Enable breakpoints in Twig files by adding to ``.vscode/settings.json``: + +.. code-block:: json + + { + "debug.allowBreakpointsEverywhere": true + } + +2. Create a ``.vscode/launch.json`` with a basic configuration in your project: + +.. code-block:: json + + { + "version": "0.2.0", + "configurations": [{ + "name": "Listen for Xdebug", + "type": "php", + "request": "launch", + "port": 9003 + }] + } + .. _callback: https://www.php.net/manual/en/function.is-callable.php diff --git a/src/Cache/ChainCache.php b/src/Cache/ChainCache.php index 1c2098f1f98..1637659726d 100644 --- a/src/Cache/ChainCache.php +++ b/src/Cache/ChainCache.php @@ -19,7 +19,7 @@ * * @author Quentin Devos */ -final class ChainCache implements CacheInterface, RemovableCacheInterface +final class ChainCache implements CacheInterface, DirectoryCacheInterface, RemovableCacheInterface { /** * @param iterable $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 } } + public function getDirectories(): array + { + $directories = []; + foreach ($this->caches as $cache) { + if ($cache instanceof DirectoryCacheInterface) { + $directories[] = $cache->getDirectories(); + } + } + + return array_merge(...$directories); + } + /** * @return string[] */ diff --git a/src/Cache/DirectoryCacheInterface.php b/src/Cache/DirectoryCacheInterface.php new file mode 100644 index 00000000000..59df932f770 --- /dev/null +++ b/src/Cache/DirectoryCacheInterface.php @@ -0,0 +1,25 @@ + + */ +interface DirectoryCacheInterface +{ + /** + * @return string[] + */ + public function getDirectories(): array; +} diff --git a/src/Cache/FilesystemCache.php b/src/Cache/FilesystemCache.php index 5840585e3e9..cc4c66da3c7 100644 --- a/src/Cache/FilesystemCache.php +++ b/src/Cache/FilesystemCache.php @@ -16,7 +16,7 @@ * * @author Andrew Tch */ -class FilesystemCache implements CacheInterface, RemovableCacheInterface +class FilesystemCache implements CacheInterface, DirectoryCacheInterface, RemovableCacheInterface { public const FORCE_BYTECODE_INVALIDATION = 1; @@ -29,6 +29,11 @@ public function __construct(string $directory, int $options = 0) $this->options = $options; } + public function getDirectories(): array + { + return [$this->directory]; + } + public function generateKey(string $name, string $className): string { $hash = hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $className); diff --git a/src/Cache/XdebugSourceMapCache.php b/src/Cache/XdebugSourceMapCache.php new file mode 100644 index 00000000000..dc7fdd7f500 --- /dev/null +++ b/src/Cache/XdebugSourceMapCache.php @@ -0,0 +1,161 @@ + + * + * @internal + */ +final class XdebugSourceMapCache implements CacheInterface, RemovableCacheInterface +{ + private ?string $pendingTemplatePath = null; + private ?array $pendingDebugInfo = null; + + public function __construct( + private DirectoryCacheInterface&CacheInterface $cache, + ) { + $this->registerExistingSourceMaps(); + } + + /** + * Registers all existing source map files with Xdebug. + * + * This is called at startup to ensure previously compiled templates + * have their source maps available for debugging. + */ + private function registerExistingSourceMaps(): void + { + foreach ($this->cache->getDirectories() as $directory) { + $mapPath = $directory.'/.xdebug'; + if (!is_dir($mapPath)) { + continue; + } + + foreach (glob($mapPath.'/*.map') as $mapFile) { + xdebug_set_source_map($mapFile); + } + } + } + + public function setSourceMapData(string $templatePath, array $debugInfo): void + { + $this->pendingTemplatePath = $templatePath; + $this->pendingDebugInfo = $debugInfo; + } + + public function generateKey(string $name, string $className): string + { + return $this->cache->generateKey($name, $className); + } + + public function write(string $key, string $content): void + { + $this->cache->write($key, $content); + + if ($this->pendingTemplatePath && $this->pendingDebugInfo) { + try { + $this->writeSourceMap($key, $this->pendingTemplatePath, $this->pendingDebugInfo); + } finally { + $this->pendingTemplatePath = null; + $this->pendingDebugInfo = null; + } + } + } + + public function load(string $key): void + { + $this->cache->load($key); + } + + public function getTimestamp(string $key): int + { + return $this->cache->getTimestamp($key); + } + + public function remove(string $name, string $className): void + { + if ($this->cache instanceof RemovableCacheInterface) { + $this->cache->remove($name, $className); + } + + $this->removeSourceMap($this->cache->generateKey($name, $className)); + } + + private function writeSourceMap(string $cacheKey, string $templatePath, array $debugInfo): void + { + $content = $this->buildMapContent($cacheKey, $templatePath, $debugInfo); + $filename = pathinfo($cacheKey, \PATHINFO_FILENAME).'.map'; + + foreach ($this->cache->getDirectories() as $directory) { + $mapPath = $directory.'/.xdebug'; + + if (!is_dir($mapPath)) { + mkdir($mapPath, 0777, true); + } + + $mapFile = $mapPath.'/'.$filename; + $tmpFile = tempnam($mapPath, 'map'); + if (false !== @file_put_contents($tmpFile, $content) && @rename($tmpFile, $mapFile)) { + @chmod($mapFile, 0666 & ~umask()); + xdebug_set_source_map($mapFile); + + continue; + } + + throw new \RuntimeException(\sprintf('Failed to write Xdebug source map file "%s".', $mapFile)); + } + } + + private function removeSourceMap(string $cacheKey): void + { + $filename = pathinfo($cacheKey, \PATHINFO_FILENAME).'.map'; + + foreach ($this->cache->getDirectories() as $directory) { + $mapFile = $directory.'/.xdebug/'.$filename; + if (is_file($mapFile)) { + @unlink($mapFile); + } + } + } + + private function buildMapContent(string $cacheKey, string $templatePath, array $debugInfo): string + { + $lines = "# Xdebug source map for Twig template\n"; + $lines .= \sprintf("remote_prefix: %s/\n", \dirname($cacheKey)); + $lines .= \sprintf("local_prefix: %s/\n\n", \dirname($templatePath)); + + $compiledLines = array_keys($debugInfo); + for ($i = 0, $count = \count($compiledLines); $i < $count; ++$i) { + $startLine = $compiledLines[$i]; + // End line is exclusive, so use next start line minus 1 + // Use a reasonable max for last range (Xdebug can't handle PHP_INT_MAX) + $endLine = isset($compiledLines[$i + 1]) ? $compiledLines[$i + 1] - 1 : 999999; + $lines .= \sprintf( + "%s:%d-%d = %s:%d\n", + basename($cacheKey), + $startLine, + $endLine, + basename($templatePath), + $debugInfo[$startLine], + ); + } + + return $lines; + } +} diff --git a/src/Environment.php b/src/Environment.php index 78f24f08726..08044fc8038 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -12,9 +12,11 @@ namespace Twig; use Twig\Cache\CacheInterface; +use Twig\Cache\DirectoryCacheInterface; use Twig\Cache\FilesystemCache; use Twig\Cache\NullCache; use Twig\Cache\RemovableCacheInterface; +use Twig\Cache\XdebugSourceMapCache; use Twig\Error\Error; use Twig\Error\LoaderError; use Twig\Error\RuntimeError; @@ -107,6 +109,10 @@ class Environment * * use_yield: true: forces templates to exclusively use "yield" instead of "echo" (all extensions must be yield ready) * false (default): allows templates to use a mix of "yield" and "echo" calls to allow for a progressive migration * Switch to "true" when possible as this will be the only supported mode in Twig 4.0 + * + * * xdebug_source_map: Whether to generate Xdebug source map files for debugging Twig templates. + * Xdebug 3.5+ can use these maps to set breakpoints in Twig templates. + * Defaults to the value of the debug option. */ public function __construct(LoaderInterface $loader, array $options = []) { @@ -121,6 +127,7 @@ public function __construct(LoaderInterface $loader, array $options = []) 'auto_reload' => null, 'optimizations' => -1, 'use_yield' => false, + 'xdebug_source_map' => null, ], $options); $this->useYield = (bool) $options['use_yield']; @@ -129,6 +136,7 @@ public function __construct(LoaderInterface $loader, array $options = []) $this->autoReload = null === $options['auto_reload'] ? $this->debug : (bool) $options['auto_reload']; $this->strictVariables = (bool) $options['strict_variables']; $this->setCache($options['cache']); + $this->initializeXdebugSourceMap($options['xdebug_source_map']); $this->extensionSet = new ExtensionSet(); $this->defaultRuntimeLoader = new FactoryRuntimeLoader([ EscaperRuntime::class => function () { return new EscaperRuntime($this->charset); }, @@ -407,6 +415,9 @@ public function loadTemplate(string $cls, string $name, ?int $index = null): Tem $source = $this->getLoader()->getSourceContext($name); $content = $this->compileSource($source); if (!isset($this->hotCache[$name])) { + if ($this->cache instanceof XdebugSourceMapCache && $source->getPath()) { + $this->cache->setSourceMapData($source->getPath(), $this->compiler->getDebugInfo()); + } $this->cache->write($key, $content); $this->cache->load($key); } @@ -950,4 +961,15 @@ private function updateOptionsHash(): void $this->useYield ? '1' : '0', ]); } + + private function initializeXdebugSourceMap(?bool $enabled): void + { + $enabled ??= $this->debug; + + if (!$enabled || !$this->cache instanceof DirectoryCacheInterface || !\function_exists('xdebug_set_source_map')) { + return; + } + + $this->cache = new XdebugSourceMapCache($this->cache); + } } diff --git a/tests/Cache/XdebugSourceMapCacheTest.php b/tests/Cache/XdebugSourceMapCacheTest.php new file mode 100644 index 00000000000..df02a6d8ef3 --- /dev/null +++ b/tests/Cache/XdebugSourceMapCacheTest.php @@ -0,0 +1,150 @@ +directory = sys_get_temp_dir().'/twig-xdebug-test'; + $this->mapDirectory = $this->directory.'/.xdebug'; + } + + protected function tearDown(): void + { + if (file_exists($this->directory)) { + FilesystemHelper::removeDir($this->directory); + } + } + + public function testWriteCreatesSourceMap() + { + $innerCache = new FilesystemCache($this->directory); + $cache = new XdebugSourceMapCache($innerCache); + + $key = $cache->generateKey('test.twig', 'TestClass'); + $templatePath = '/path/to/templates/test.twig'; + $debugInfo = [10 => 1, 20 => 5, 30 => 10]; + + $cache->setSourceMapData($templatePath, $debugInfo); + $cache->write($key, 'mapDirectory.'/'.pathinfo($key, \PATHINFO_FILENAME).'.map'; + $this->assertFileExists($mapFile); + + $content = file_get_contents($mapFile); + $this->assertStringContainsString('# Xdebug source map for Twig template', $content); + $this->assertStringContainsString('remote_prefix: '.\dirname($key).'/', $content); + $this->assertStringContainsString('local_prefix: /path/to/templates/', $content); + $compiledFile = basename($key); + $this->assertStringContainsString($compiledFile.':10-19 = test.twig:1', $content); + $this->assertStringContainsString($compiledFile.':20-29 = test.twig:5', $content); + $this->assertStringContainsString($compiledFile.':30-999999 = test.twig:10', $content); + } + + public function testWriteWithoutSourceMapDataDoesNotCreateMapFile() + { + $innerCache = new FilesystemCache($this->directory); + $cache = new XdebugSourceMapCache($innerCache); + + $key = $cache->generateKey('test.twig', 'TestClass'); + $cache->write($key, 'mapDirectory.'/'.pathinfo($key, \PATHINFO_FILENAME).'.map'; + $this->assertFileDoesNotExist($mapFile); + } + + public function testWriteClearsPendingDataAfterWrite() + { + $innerCache = new FilesystemCache($this->directory); + $cache = new XdebugSourceMapCache($innerCache); + + $key1 = $cache->generateKey('test1.twig', 'TestClass1'); + $key2 = $cache->generateKey('test2.twig', 'TestClass2'); + + $cache->setSourceMapData('/path/test1.twig', [10 => 1]); + $cache->write($key1, 'write($key2, 'mapDirectory.'/'.pathinfo($key1, \PATHINFO_FILENAME).'.map'; + $mapFile2 = $this->mapDirectory.'/'.pathinfo($key2, \PATHINFO_FILENAME).'.map'; + + $this->assertFileExists($mapFile1); + $this->assertFileDoesNotExist($mapFile2); + } + + public function testRemoveDeletesSourceMap() + { + $innerCache = new FilesystemCache($this->directory); + $cache = new XdebugSourceMapCache($innerCache); + + $key = $cache->generateKey('test.twig', 'TestClass'); + $cache->setSourceMapData('/path/test.twig', [10 => 1]); + $cache->write($key, 'mapDirectory.'/'.pathinfo($key, \PATHINFO_FILENAME).'.map'; + $this->assertFileExists($mapFile); + + $cache->remove('test.twig', 'TestClass'); + + $this->assertFileDoesNotExist($mapFile); + } + + public function testDelegatesGenerateKey() + { + $innerCache = new FilesystemCache($this->directory); + $cache = new XdebugSourceMapCache($innerCache); + + $expected = $innerCache->generateKey('test.twig', 'TestClass'); + $this->assertSame($expected, $cache->generateKey('test.twig', 'TestClass')); + } + + public function testDelegatesGetTimestamp() + { + $innerCache = new FilesystemCache($this->directory); + $cache = new XdebugSourceMapCache($innerCache); + + $key = $cache->generateKey('test.twig', 'TestClass'); + $cache->write($key, 'assertSame($innerCache->getTimestamp($key), $cache->getTimestamp($key)); + } + + public function testDelegatesLoad() + { + $nonce = hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', random_bytes(32)); + $className = '__Twig_Tests_Cache_XdebugSourceMapCacheTest_'.$nonce; + + $innerCache = new FilesystemCache($this->directory); + $cache = new XdebugSourceMapCache($innerCache); + + $key = $cache->generateKey('test.twig', $className); + $cache->write($key, 'assertFalse(class_exists($className, false)); + $cache->load($key); + $this->assertTrue(class_exists($className, false)); + } +} diff --git a/tests/EnvironmentTest.php b/tests/EnvironmentTest.php index 2c568d79bd7..1e5a776fcb8 100644 --- a/tests/EnvironmentTest.php +++ b/tests/EnvironmentTest.php @@ -574,6 +574,94 @@ public function testHotCache() FilesystemHelper::removeDir($dir); } } + + /** + * @requires function xdebug_set_source_map + */ + public function testXdebugSourceMapEnabledByDefault() + { + $dir = sys_get_temp_dir().'/twig-xdebug-map-test-'.bin2hex(random_bytes(8)); + @mkdir($dir); + @mkdir($dir.'/templates'); + file_put_contents($dir.'/templates/index.twig', '{{ foo }}'); + + try { + // xdebug_source_map defaults to the value of debug + $twig = new Environment(new FilesystemLoader($dir.'/templates'), [ + 'debug' => true, + 'cache' => $dir.'/cache', + ]); + + $twig->render('index.twig', ['foo' => 'bar']); + + // Check that the .xdebug directory was created inside the cache directory + $this->assertDirectoryExists($dir.'/cache/.xdebug'); + $mapFiles = glob($dir.'/cache/.xdebug/*.map'); + $this->assertCount(1, $mapFiles); + } finally { + FilesystemHelper::removeDir($dir); + } + } + + /** + * @requires function xdebug_set_source_map + */ + public function testXdebugSourceMapCanBeDisabled() + { + $dir = sys_get_temp_dir().'/twig-xdebug-map-test-'.bin2hex(random_bytes(8)); + @mkdir($dir); + @mkdir($dir.'/templates'); + file_put_contents($dir.'/templates/index.twig', '{{ foo }}'); + + try { + $twig = new Environment(new FilesystemLoader($dir.'/templates'), [ + 'debug' => true, + 'cache' => $dir.'/cache', + 'xdebug_source_map' => false, + ]); + + $twig->render('index.twig', ['foo' => 'bar']); + + // Map directory should not exist since xdebug_source_map is false + $this->assertDirectoryDoesNotExist($dir.'/cache/.xdebug'); + } finally { + FilesystemHelper::removeDir($dir); + } + } + + /** + * @requires function xdebug_set_source_map + */ + public function testXdebugSourceMapFilesRemovedWhenCacheRemoved() + { + $dir = sys_get_temp_dir().'/twig-xdebug-map-test-'.bin2hex(random_bytes(8)); + @mkdir($dir); + @mkdir($dir.'/templates'); + file_put_contents($dir.'/templates/index.twig', '{{ foo }}'); + + try { + $twig = new Environment(new FilesystemLoader($dir.'/templates'), [ + 'debug' => true, + 'cache' => $dir.'/cache', + ]); + + $twig->render('index.twig', ['foo' => 'bar']); + + // Verify map file exists + $mapFiles = glob($dir.'/cache/.xdebug/*.map'); + $this->assertCount(1, $mapFiles); + $mapFile = $mapFiles[0]; + $this->assertFileExists($mapFile); + + // Remove cache + $twig->removeCache('index.twig'); + + // Map file should be removed + $this->assertFileDoesNotExist($mapFile); + } finally { + FilesystemHelper::removeDir($dir); + } + } } class EnvironmentTest_Extension_WithGlobals extends AbstractExtension