-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Add support for XDebug path mapping #4733
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 3.x
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * This file is part of Twig. | ||
| * | ||
| * (c) Fabien Potencier | ||
| * | ||
| * For the full copyright and license information, please view the LICENSE | ||
| * file that was distributed with this source code. | ||
| */ | ||
|
|
||
| namespace Twig\Cache; | ||
|
|
||
| /** | ||
| * Interface for caches that store files in directories. | ||
| * | ||
| * @author Fabien Potencier <fabien@symfony.com> | ||
| */ | ||
| interface DirectoryCacheInterface | ||
| { | ||
| /** | ||
| * @return string[] | ||
| */ | ||
| public function getDirectories(): array; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * This file is part of Twig. | ||
| * | ||
| * (c) Fabien Potencier | ||
| * | ||
| * For the full copyright and license information, please view the LICENSE | ||
| * file that was distributed with this source code. | ||
| */ | ||
|
|
||
| namespace Twig\Cache; | ||
|
|
||
| /** | ||
| * Cache decorator that writes Xdebug source map files alongside compiled templates. | ||
| * | ||
| * Xdebug 3.5+ supports native path mapping via .map files in .xdebug directories. | ||
| * This allows setting breakpoints in Twig templates and having Xdebug map them | ||
| * to the correct lines in the compiled PHP files. | ||
| * | ||
| * @author Fabien Potencier <fabien@symfony.com> | ||
| * | ||
| * @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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't like it that you need to use a random largish constant ( Would that be helpful?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it would definitely be helpful here. |
||
| $lines .= \sprintf( | ||
| "%s:%d-%d = %s:%d\n", | ||
| basename($cacheKey), | ||
| $startLine, | ||
| $endLine, | ||
| basename($templatePath), | ||
| $debugInfo[$startLine], | ||
| ); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I imagine, this gets large, as there is a separate entry in the source file for each line. I am planning on optimising this, using how JavaScript source maps approach the line-to-line mappings, if possible. I would also recommend that if |
||
| } | ||
|
|
||
| return $lines; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function is not available yet but this is the cleaneast way to do it IMHO.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is there any public discussion on the Xdebug side about this function ?
Btw, the verb
setin the name is confusing if it registers a new source map without removing the previous one, as that's not the usual meaning ofsetin function names.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As this is a feature that I haven't added yet, I am more than happy to discuss how it should work. I agree that using
xdebug_add_source_mapis probably a better choice. Right now, the internals will reject a new source map if it touches the same files though, and that seems not so easy to change.Alternatively, I could add a new function
xdebug_add_source_map_directory, which you would only have to call once before the compiled template files are loaded into PHP's memory through include.Then Xdebug can (additionally) scan this directory to pick up any source maps that exist.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
xdebug_add_source_map_directorymight be better for the Twig use case indeed (and might maybe have less overhead for requests not using breakpoints by skipping the filesystem scanning, depending on when xdebug actually traverses those directories)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It currently scans the files (if path mapping is enabled) at the start of every request. Although for now, it only works with the step debugger, in the future I want to extend the support to maps to other features too: https://xdebug.org/funding/001-native-path-mapping#future-scope