diff --git a/docs/yaml/functions/custom_target.yaml b/docs/yaml/functions/custom_target.yaml index 9a26e4d8f..26d51473a 100644 --- a/docs/yaml/functions/custom_target.yaml +++ b/docs/yaml/functions/custom_target.yaml @@ -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] diff --git a/mesonbuild/backend/backends.py b/mesonbuild/backend/backends.py index 76c5d32e4..dc8d63067 100644 --- a/mesonbuild/backend/backends.py +++ b/mesonbuild/backend/backends.py @@ -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) diff --git a/mesonbuild/backend/ninjabackend.py b/mesonbuild/backend/ninjabackend.py index 7a43b1d75..79a791a2f 100644 --- a/mesonbuild/backend/ninjabackend.py +++ b/mesonbuild/backend/ninjabackend.py @@ -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) diff --git a/mesonbuild/build.py b/mesonbuild/build.py index 4ed045b72..f735a6ff4 100644 --- a/mesonbuild/build.py +++ b/mesonbuild/build.py @@ -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')): diff --git a/test cases/common/49 custom target/meson.build b/test cases/common/49 custom target/meson.build index a51e526f6..d9a2952e0 100644 --- a/test cases/common/49 custom target/meson.build +++ b/test cases/common/49 custom target/meson.build @@ -71,3 +71,4 @@ endif assert(mytarget_disabled, 'Disabled custom target should not be found.') subdir('depfile') +subdir('outputdepfile') diff --git a/test cases/common/49 custom target/outputdepfile/gen.py b/test cases/common/49 custom target/outputdepfile/gen.py new file mode 100644 index 000000000..be1a07af3 --- /dev/null +++ b/test cases/common/49 custom target/outputdepfile/gen.py @@ -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)) diff --git a/test cases/common/49 custom target/outputdepfile/meson.build b/test cases/common/49 custom target/outputdepfile/meson.build new file mode 100644 index 000000000..75eab1b21 --- /dev/null +++ b/test cases/common/49 custom target/outputdepfile/meson.build @@ -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@'], +) diff --git a/unittests/allplatformstests.py b/unittests/allplatformstests.py index ac5519281..15b0d9f39 100644 --- a/unittests/allplatformstests.py +++ b/unittests/allplatformstests.py @@ -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')