backend: substitute @OUTPUT@ in custom_target depfile

@OUTPUT@ in depfile is the output filename, not the full path.
Joining the target directory with an unexpanded @OUTPUT@ token, then
substituting the full output path, doubled the subdirectory in both
@DEPFILE@ and ninja's depfile (include/include/foo.d).

Fixes #14140
This commit is contained in:
Zhaoqi Xu authored and Paolo Bonzini committed 2026-10-01 12:20:22 +02:00
1 parent a7ce13c354
commit 078db80a47
8 files changed
+105 -10

No files matched your search

+7 -2
View File
@@ -50,7 +50,10 @@ description: |
this may be an absolute or a relative to current workdir path.
*(since 0.47.0)* The `depfile` keyword argument also accepts the
`@BASENAME@` and `@PLAINNAME@` substitutions.
`@BASENAME@` and `@PLAINNAME@` substitutions. `@OUTPUT@` and
`@OUTPUT0@`, `@OUTPUT1@`, `...` are substituted with the output
filename (without the target subdirectory), so `depfile: '@OUTPUT@.d'`
is written next to the output file.
The returned object also has methods that are documented in [[@custom_tgt]].
@@ -186,7 +189,9 @@ kwargs:
in any one of these files triggers a recompilation.
*(since 0.47.0)* the `@BASENAME@` and `@PLAINNAME@` substitutions
are also accepted.
are also accepted. `@OUTPUT@` / `@OUTPUT0@` `...` are substituted
with the output filename so the depfile is not placed in a doubled
subdirectory path.
input:
type: array[str | file | program]
+1 -1
View File
@@ -1705,7 +1705,7 @@ class Backend:
msg = f'Custom target {target.name!r} has @DEPFILE@ but no depfile ' \
'keyword argument.'
raise MesonException(msg)
dfilename = os.path.join(outdir, target.depfile)
dfilename = os.path.join(outdir, target.get_dep_outname(inputs, target.get_outputs()))
i = i.replace('@DEPFILE@', dfilename)
if '@PRIVATE_DIR@' in i:
assert isinstance(target, build.CustomTarget)
+1 -1
View File
@@ -1334,7 +1334,7 @@ class NinjaBackend(backends.Backend):
else:
cmd_type = ''
if target.depfile is not None:
depfile = target.get_dep_outname(elem.infilenames)
depfile = target.get_dep_outname(elem.infilenames, target.get_outputs())
rel_dfile = os.path.join(self.get_target_dir(target), depfile)
abs_pdir = os.path.join(self.environment.get_build_dir(), self.get_target_dir(target))
os.makedirs(abs_pdir, exist_ok=True)
+19 -6
View File
@@ -3244,17 +3244,30 @@ class CustomTarget(Target, CustomTargetBase, CommandTargetProto):
def get_generated_sources(self) -> T.Iterable[GeneratedTypes]:
return self.get_generated_lists()
def get_dep_outname(self, infilenames: list[str]) -> str:
def get_dep_outname(self, infilenames: list[str],
outfilenames: T.Optional[list[str]] = None) -> str:
if self.depfile is None:
raise InvalidArguments('Tried to get depfile name for custom_target that does not have depfile defined.')
depfile = self.depfile
if infilenames:
plainname = os.path.basename(infilenames[0])
basename = os.path.splitext(plainname)[0]
return self.depfile.replace('@BASENAME@', basename).replace('@PLAINNAME@', plainname)
else:
if '@BASENAME@' in self.depfile or '@PLAINNAME@' in self.depfile:
raise InvalidArguments('Substitution in depfile for custom_target that does not have an input file.')
return self.depfile
depfile = depfile.replace('@BASENAME@', basename).replace('@PLAINNAME@', plainname)
elif '@BASENAME@' in depfile or '@PLAINNAME@' in depfile:
raise InvalidArguments('Substitution in depfile for custom_target that does not have an input file.')
# @OUTPUT@ in depfile is the output *filename*, not the full path.
# The backend joins this with get_target_dir(), which already includes
# the meson subdir and build_subdir. Substituting a full @OUTPUT@ path
# would double both (issue #14140).
outputs = outfilenames if outfilenames is not None else self.outputs
if '@OUTPUT@' in depfile:
if len(outputs) != 1:
raise InvalidArguments("Depfile has '@OUTPUT@' as part of a "
'string and more than one output file')
depfile = depfile.replace('@OUTPUT@', os.path.basename(outputs[0]))
for i, ofile in enumerate(outputs):
depfile = depfile.replace(f'@OUTPUT{i}@', os.path.basename(ofile))
return depfile
def is_linkable_output(self, output: str) -> bool:
if output.endswith(('.a', '.dll', '.lib', '.so', '.dylib')):
@@ -71,3 +71,4 @@ endif
assert(mytarget_disabled, 'Disabled custom target should not be found.')
subdir('depfile')
subdir('outputdepfile')
@@ -0,0 +1,17 @@
#!/usr/bin/env python3
import sys
_, depfile, output = sys.argv
# Record the path Meson passed as @DEPFILE@ so tests can check it even
# after ninja has ingested (and discarded) the gcc-style depfile.
with open(output + '.path', 'w', encoding='utf-8') as f:
f.write(depfile.replace('\\', '/'))
with open(output, 'w', encoding='utf-8') as f:
f.write('generated\n')
quoted_dep = sys.argv[0].replace('\\', '/').replace(' ', r'\ ')
with open(depfile, 'w', encoding='utf-8') as f:
f.write('{}: {}\n'.format(output.replace('\\', '/'), quoted_dep))
@@ -0,0 +1,17 @@
# Issue #14140: depfile: '@OUTPUT@.d' in a subdirectory must not
# double the subdirectory in @DEPFILE@ or in ninja's depfile path.
custom_target('outputdepfile',
output : 'out.dat',
depfile : '@OUTPUT@.d',
build_by_default : true,
command : [find_program('gen.py'), '@DEPFILE@', '@OUTPUT@'],
)
# build_subdir is part of the target directory, not of @OUTPUT@'s filename.
custom_target('outputdepfile-build-subdir',
output : 'out.dat',
depfile : '@OUTPUT@.d',
build_subdir : 'placed',
build_by_default : true,
command : [find_program('gen.py'), '@DEPFILE@', '@OUTPUT@'],
)
+42
View File
@@ -5711,6 +5711,48 @@ class AllPlatformTests(BasePlatformTests):
self.assertIn('Generating file.txt with a custom command', out)
self.assertIn('Generating subdir/file.txt with a custom command', out)
def test_custom_target_depfile_output_substitution(self):
'''depfile: '@OUTPUT@.d' in a subdirectory must not double the path.
https://github.com/mesonbuild/meson/issues/14140
'''
testdir = os.path.join(self.common_test_dir, '49 custom target')
self.init(testdir)
self.build()
output = os.path.join(self.builddir, 'outputdepfile', 'out.dat')
self.assertPathExists(output)
self.assertPathDoesNotExist(os.path.join(self.builddir, 'outputdepfile', 'outputdepfile', 'out.dat.d'))
with open(output + '.path', encoding='utf-8') as f:
depfile_arg = f.read().strip().replace('\\', '/')
# Ninja passes a builddir-relative path; Visual Studio uses an
# absolute path. Either is fine so long as the subdirectory is
# not doubled (issue #14140).
self.assertTrue(
depfile_arg == 'outputdepfile/out.dat.d'
or depfile_arg.endswith('/outputdepfile/out.dat.d'),
depfile_arg)
self.assertNotIn('outputdepfile/outputdepfile/', depfile_arg)
placed = os.path.join(self.builddir, 'outputdepfile', 'placed', 'out.dat')
self.assertPathExists(placed)
self.assertPathDoesNotExist(os.path.join(
self.builddir, 'outputdepfile', 'placed', 'outputdepfile', 'placed', 'out.dat.d'))
with open(placed + '.path', encoding='utf-8') as f:
placed_arg = f.read().strip().replace('\\', '/')
self.assertTrue(
placed_arg == 'outputdepfile/placed/out.dat.d'
or placed_arg.endswith('/outputdepfile/placed/out.dat.d'),
placed_arg)
self.assertNotIn('placed/outputdepfile/placed/', placed_arg)
if self.backend is Backend.ninja:
with open(os.path.join(self.builddir, 'build.ninja'), encoding='utf-8') as f:
contents = f.read().replace('\\', '/')
self.assertIn('outputdepfile/out.dat.d', contents)
self.assertNotIn('outputdepfile/outputdepfile/out.dat.d', contents)
self.assertIn('outputdepfile/placed/out.dat.d', contents)
self.assertNotIn('outputdepfile/placed/outputdepfile/placed/out.dat.d', contents)
self.assertNotIn('@OUTPUT@.d', contents)
def test_symlinked_subproject(self):
testdir = os.path.join(self.unit_test_dir, '107 subproject symlink')
subproject_dir = os.path.join(testdir, 'subprojects')