From 93e431abd3e8bad89f076a2beab7b1e4598985c7 Mon Sep 17 00:00:00 2001 From: Jan Niklas Hasse Date: Sun, 12 Apr 2026 15:25:27 +0200 Subject: [PATCH] Fall back to $builddir/target when target lookup fails When $builddir is set in the manifest, passing "foo" on the command line now checks for a target named "foo" first, then falls back to "$builddir/foo" before reporting an error. --- src/ninja.cc | 10 ++ tests/builddir_target/test_builddir_target.py | 119 ++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 tests/builddir_target/test_builddir_target.py diff --git a/src/ninja.cc b/src/ninja.cc index 171d6f0b..0b17dc1f 100644 --- a/src/ninja.cc +++ b/src/ninja.cc @@ -331,6 +331,16 @@ Node* NinjaMain::CollectTarget(const char* cpath, string* err) { } Node* node = state_.LookupNode(path); + if (!node && !build_dir_.empty()) { + string builddir_path = build_dir_ + "/" + path; + uint64_t builddir_slash_bits; + CanonicalizePath(&builddir_path, &builddir_slash_bits); + node = state_.LookupNode(builddir_path); + if (node) { + path = builddir_path; + slash_bits = builddir_slash_bits; + } + } if (node) { if (first_dependent) { if (node->out_edges().empty()) { diff --git a/tests/builddir_target/test_builddir_target.py b/tests/builddir_target/test_builddir_target.py new file mode 100644 index 00000000..583357ef --- /dev/null +++ b/tests/builddir_target/test_builddir_target.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 + +"""Integration test for target lookup with builddir fallback.""" + +import os +import shutil +import subprocess +import tempfile +import unittest + +NINJA_PATH = os.path.abspath("./ninja") + + +class BuilddirTargetTest(unittest.TestCase): + """Test that targets can be found via $builddir fallback.""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="ninja_builddir_target_test_") + self.original_dir = os.getcwd() + os.chdir(self.test_dir) + + def tearDown(self): + os.chdir(self.original_dir) + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _run_ninja(self, *args): + result = subprocess.run( + [NINJA_PATH] + list(args), capture_output=True, text=True + ) + return result + + def test_builddir_fallback(self): + """Passing 'foo' should build '$builddir/foo' when no literal 'foo' target exists.""" + with open("build.ninja", "w") as f: + f.write( + "builddir = out\n" + "\n" + "rule touch\n" + " command = touch $out\n" + "\n" + "build $builddir/foo: touch\n" + ) + + result = self._run_ninja("foo") + self.assertEqual(result.returncode, 0, f"Build failed: {result.stderr}") + self.assertTrue(os.path.exists("out/foo"), "out/foo was not created") + + def test_exact_match_takes_priority(self): + """A literal target 'foo' should be preferred over '$builddir/foo'.""" + with open("build.ninja", "w") as f: + f.write( + "builddir = out\n" + "\n" + "rule cp\n" + " command = cp $in $out\n" + "\n" + "rule touch\n" + " command = touch $out\n" + "\n" + "build foo: touch\n" + "build $builddir/foo: cp foo\n" + ) + + result = self._run_ninja("foo") + self.assertEqual(result.returncode, 0, f"Build failed: {result.stderr}") + # Only the exact-match target should have been built; out/foo depends on + # foo so if ninja built out/foo both would exist, but requesting just + # "foo" should only build the literal target. + self.assertTrue(os.path.exists("foo"), "foo was not created") + self.assertFalse( + os.path.exists("out/foo"), + "out/foo should not have been built when exact 'foo' exists", + ) + + def test_no_builddir_no_fallback(self): + """Without builddir, an unknown target should still fail.""" + with open("build.ninja", "w") as f: + f.write( + "rule touch\n" + " command = touch $out\n" + "\n" + "build bar: touch\n" + ) + + result = self._run_ninja("nonexistent") + self.assertNotEqual(result.returncode, 0) + self.assertIn("unknown target", result.stderr) + + def test_fallback_with_subdirectory(self): + """Fallback should work for paths with subdirectories.""" + with open("build.ninja", "w") as f: + f.write( + "builddir = out\n" + "\n" + "rule touch\n" + " command = mkdir -p `dirname $out` && touch $out\n" + "\n" + "build $builddir/sub/bar: touch\n" + ) + + result = self._run_ninja("sub/bar") + self.assertEqual(result.returncode, 0, f"Build failed: {result.stderr}") + self.assertTrue(os.path.exists("out/sub/bar"), "out/sub/bar was not created") + + def test_fallback_miss_still_errors(self): + """When the target isn't found even under $builddir, an error is reported.""" + with open("build.ninja", "w") as f: + f.write( + "builddir = out\n" + "\n" + "rule touch\n" + " command = touch $out\n" + "\n" + "build $builddir/foo: touch\n" + ) + + result = self._run_ninja("nonexistent") + self.assertNotEqual(result.returncode, 0) + self.assertIn("unknown target", result.stderr)