Repository: php/pie (php)

Issue:
Support building when package is not in the root directory
From @zeriyoshi - original proposal in #82 

> Add `extension-src` directive to the `php-ext` section in `composer.json`.
> 
> This enables support for patterns where extension source code is not located in the project root, which is commonly seen in third-party extensions.
> 
> https://github.com/protocolbuffers/protobuf/tree/main/php/ext/google/protobuf
> 
> ```
> {
>     "name": "google/protobuf",
>     "type": "php-ext",
>     "description": "protocolbuffers php extension",
>     "php-ext": {
>         "extension-name": "protobuf",
>         "extension-src": "php/ext/google/protobuf"
>     }
> }
> ```

Investigate options, check this makes sense and is clear. Documentation would be needed, and upstream Composer schema support.

Pull request description:
Added support for build-path php-ext configuration
Fixes #84 

Supersedes #82 

Patch:
diff --git a/composer.json b/composer.json
index dbea947..44a8b9e 100644
--- a/composer.json
+++ b/composer.json
@@ -29,7 +29,7 @@
     "require": {
         "php": "8.1.*||8.2.*||8.3.*||8.4.*",
         "ext-zip": "*",
-        "composer/composer": "^2.8.2",
+        "composer/composer": "dev-main",
         "fidry/cpu-core-counter": "^1.2",
         "illuminate/container": "^10.47",
         "symfony/console": "^6.4",
diff --git a/composer.lock b/composer.lock
index b3ca2c0..8c02fb3 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "bfad0142b20e5d4d1bd2845a5c60fdb8",
+    "content-hash": "b4784c9fd1c30ffc86bc4c24b3789654",
     "packages": [
         {
             "name": "composer/ca-bundle",
@@ -157,16 +157,16 @@
         },
         {
             "name": "composer/composer",
-            "version": "2.8.3",
+            "version": "dev-main",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/composer.git",
-                "reference": "2a7c71266b2545a3bed9f4860734081963f6e688"
+                "reference": "38cb4bfe71dc3a9093cb8b4ecebfd39cb1a5699c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/composer/zipball/2a7c71266b2545a3bed9f4860734081963f6e688",
-                "reference": "2a7c71266b2545a3bed9f4860734081963f6e688",
+                "url": "https://api.github.com/repos/composer/composer/zipball/38cb4bfe71dc3a9093cb8b4ecebfd39cb1a5699c",
+                "reference": "38cb4bfe71dc3a9093cb8b4ecebfd39cb1a5699c",
                 "shasum": ""
             },
             "require": {
@@ -205,6 +205,7 @@
                 "ext-zip": "Enabling the zip extension allows you to unzip archives",
                 "ext-zlib": "Allow gzip compression of HTTP requests"
             },
+            "default-branch": true,
             "bin": [
                 "bin/composer"
             ],
@@ -251,7 +252,7 @@
                 "irc": "ircs://irc.libera.chat:6697/composer",
                 "issues": "https://github.com/composer/composer/issues",
                 "security": "https://github.com/composer/composer/security/policy",
-                "source": "https://github.com/composer/composer/tree/2.8.3"
+                "source": "https://github.com/composer/composer/tree/main"
             },
             "funding": [
                 {
@@ -267,7 +268,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-11-17T12:13:04+00:00"
+            "time": "2024-11-21T08:16:24+00:00"
         },
         {
             "name": "composer/metadata-minifier",
@@ -5939,7 +5940,9 @@
     ],
     "aliases": [],
     "minimum-stability": "stable",
-    "stability-flags": {},
+    "stability-flags": {
+        "composer/composer": 20
+    },
     "prefer-stable": false,
     "prefer-lowest": false,
     "platform": {
diff --git a/docs/extension-maintainers.md b/docs/extension-maintainers.md
index ae25ee7..7465b12 100644
--- a/docs/extension-maintainers.md
+++ b/docs/extension-maintainers.md
@@ -132,6 +132,34 @@ If an end user does not specify a flag defined in the `configure-options`
 definition, it will simply not be passed to `./configure`. There is no way to
 specify a default value in the `configure-options` definition.
 
+#### `build-path`
+
+The `build-path` setting may be used if your source code is not in the root
+of your repository. For example, if your repository structure is like:
+
+```
+/
+  docs/
+  src/
+    config.m4
+    config.w32
+    myext.c
+    ...etc
+```
+
+In this case, the actual extension source code would be built in `src/`, so you
+should specify this path in `build-path`, for example:
+
+```json
+{
+    "name": "myvendor/myext",
+    "php-ext": {
+        "extension-name": "myext",
+        "build-path": "src"
+    }
+}
+```
+
 ### Extension dependencies
 
 Extension authors may define some dependencies in `require`, but practically,
diff --git a/src/DependencyResolver/Package.php b/src/DependencyResolver/Package.php
index f479781..818ca0d 100644
--- a/src/DependencyResolver/Package.php
+++ b/src/DependencyResolver/Package.php
@@ -36,6 +36,7 @@ final class Package
         public readonly array $configureOptions,
         public readonly bool $supportZts,
         public readonly bool $supportNts,
+        public readonly string|null $buildPath,
     ) {
     }
 
@@ -58,6 +59,10 @@ final class Package
             ? $phpExtOptions['support-nts']
             : true;
 
+        $buildPath = $phpExtOptions !== null && array_key_exists('build-path', $phpExtOptions)
+            ? $phpExtOptions['build-path']
+            : null;
+
         return new self(
             $completePackage,
             ExtensionType::tryFrom($completePackage->getType()) ?? ExtensionType::PhpModule,
@@ -68,6 +73,7 @@ final class Package
             $configureOptions,
             $supportZts,
             $supportNts,
+            $buildPath,
         );
     }
 
diff --git a/src/Downloading/DownloadedPackage.php b/src/Downloading/DownloadedPackage.php
index 0e21461..3f6adeb 100644
--- a/src/Downloading/DownloadedPackage.php
+++ b/src/Downloading/DownloadedPackage.php
@@ -6,6 +6,11 @@ namespace Php\Pie\Downloading;
 
 use Php\Pie\DependencyResolver\Package;
 
+use function is_string;
+use function realpath;
+
+use const DIRECTORY_SEPARATOR;
+
 /**
  * @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks
  *
@@ -21,6 +26,14 @@ final class DownloadedPackage
 
     public static function fromPackageAndExtractedPath(Package $package, string $extractedSourcePath): self
     {
+        if ($package->buildPath !== null) {
+            $extractedSourcePathWithBuildPath = realpath($extractedSourcePath . DIRECTORY_SEPARATOR . $package->buildPath);
+
+            if (is_string($extractedSourcePathWithBuildPath)) {
+                $extractedSourcePath = $extractedSourcePathWithBuildPath;
+            }
+        }
+
         return new self($package, $extractedSourcePath);
     }
 }


Test patch:
diff --git a/test/integration/Building/UnixBuildTest.php b/test/integration/Building/UnixBuildTest.php
index d35a1f3..ce82c94 100644
--- a/test/integration/Building/UnixBuildTest.php
+++ b/test/integration/Building/UnixBuildTest.php
@@ -20,6 +20,8 @@ use PHPUnit\Framework\TestCase;
 use Symfony\Component\Console\Output\BufferedOutput;
 use Symfony\Component\Process\Process;
 
+use function dirname;
+
 #[CoversClass(UnixBuild::class)]
 final class UnixBuildTest extends TestCase
 {
@@ -44,6 +46,7 @@ final class UnixBuildTest extends TestCase
                 [ConfigureOption::fromComposerJsonDefinition(['name' => 'enable-pie_test_ext'])],
                 true,
                 true,
+                null,
             ),
             self::TEST_EXTENSION_PATH,
         );
@@ -96,6 +99,7 @@ final class UnixBuildTest extends TestCase
                 [ConfigureOption::fromComposerJsonDefinition(['name' => 'enable-pie_test_ext'])],
                 true,
                 true,
+                null,
             ),
             self::TEST_EXTENSION_PATH,
         );
@@ -116,4 +120,57 @@ final class UnixBuildTest extends TestCase
             (new Process(['phpize', '--clean'], $downloadedPackage->extractedSourcePath))->mustRun();
         }
     }
+
+    public function testUnixBuildCanBuildExtensionWithBuildPath(): void
+    {
+        if (Platform::isWindows()) {
+            self::markTestSkipped('Unix build test cannot be run on Windows');
+        }
+
+        $output = new BufferedOutput();
+
+        $downloadedPackage = DownloadedPackage::fromPackageAndExtractedPath(
+            new Package(
+                $this->createMock(CompletePackage::class),
+                ExtensionType::PhpModule,
+                ExtensionName::normaliseFromString('pie_test_ext'),
+                'pie_test_ext',
+                '0.1.0',
+                null,
+                [ConfigureOption::fromComposerJsonDefinition(['name' => 'enable-pie_test_ext'])],
+                true,
+                true,
+                'pie_test_ext',
+            ),
+            dirname(self::TEST_EXTENSION_PATH),
+        );
+
+        $unixBuilder = new UnixBuild();
+        $builtBinary = $unixBuilder->__invoke(
+            $downloadedPackage,
+            TargetPlatform::fromPhpBinaryPath(PhpBinaryPath::fromCurrentProcess(), null),
+            ['--enable-pie_test_ext'],
+            $output,
+            null,
+        );
+
+        self::assertNotEmpty($builtBinary);
+
+        $outputString = $output->fetch();
+
+        self::assertStringContainsString('phpize complete.', $outputString);
+        self::assertStringContainsString('Configure complete with options: --enable-pie_test_ext', $outputString);
+        self::assertStringContainsString('Build complete:', $outputString);
+        self::assertStringContainsString('pie_test_ext.so', $outputString);
+
+        self::assertSame(
+            0,
+            (new Process(['make', 'test'], $downloadedPackage->extractedSourcePath))
+                ->mustRun()
+                ->getExitCode(),
+        );
+
+        (new Process(['make', 'clean'], $downloadedPackage->extractedSourcePath))->mustRun();
+        (new Process(['phpize', '--clean'], $downloadedPackage->extractedSourcePath))->mustRun();
+    }
 }
diff --git a/test/integration/Downloading/GithubPackageReleaseAssetsTest.php b/test/integration/Downloading/GithubPackageReleaseAssetsTest.php
index ae0d56c..4a1ca2f 100644
--- a/test/integration/Downloading/GithubPackageReleaseAssetsTest.php
+++ b/test/integration/Downloading/GithubPackageReleaseAssetsTest.php
@@ -51,6 +51,7 @@ final class GithubPackageReleaseAssetsTest extends TestCase
             [],
             true,
             true,
+            null,
         );
 
         $io     = $this->createMock(IOInterface::class);
diff --git a/test/integration/Installing/UnixInstallTest.php b/test/integration/Installing/UnixInstallTest.php
index 730747e..5bf7bb9 100644
--- a/test/integration/Installing/UnixInstallTest.php
+++ b/test/integration/Installing/UnixInstallTest.php
@@ -90,6 +90,7 @@ final class UnixInstallTest extends TestCase
                 [ConfigureOption::fromComposerJsonDefinition(['name' => 'enable-pie_test_ext'])],
                 true,
                 true,
+                null,
             ),
             self::TEST_EXTENSION_PATH,
         );
diff --git a/test/integration/Installing/WindowsInstallTest.php b/test/integration/Installing/WindowsInstallTest.php
index e29bedc..0905fa9 100644
--- a/test/integration/Installing/WindowsInstallTest.php
+++ b/test/integration/Installing/WindowsInstallTest.php
@@ -53,6 +53,7 @@ final class WindowsInstallTest extends TestCase
                 [],
                 true,
                 true,
+                null,
             ),
             self::TEST_EXTENSION_PATH,
         );
diff --git a/test/unit/Command/CommandHelperTest.php b/test/unit/Command/CommandHelperTest.php
index 81a0bc6..007cb72 100644
--- a/test/unit/Command/CommandHelperTest.php
+++ b/test/unit/Command/CommandHelperTest.php
@@ -109,6 +109,7 @@ final class CommandHelperTest extends TestCase
             ],
             true,
             true,
+            null,
         );
         $inputDefinition = new InputDefinition();
         $inputDefinition->addOption(new InputOption('with-stuff', null, InputOption::VALUE_REQUIRED));
diff --git a/test/unit/DependencyResolver/PackageTest.php b/test/unit/DependencyResolver/PackageTest.php
index 03a8d8b..1c3f03c 100644
--- a/test/unit/DependencyResolver/PackageTest.php
+++ b/test/unit/DependencyResolver/PackageTest.php
@@ -26,6 +26,7 @@ final class PackageTest extends TestCase
         self::assertSame('1.2.3', $package->version);
         self::assertSame('vendor/foo:1.2.3', $package->prettyNameAndVersion());
         self::assertNull($package->downloadUrl);
+        self::assertNull($package->buildPath);
     }
 
     public function testFromComposerCompletePackageWithExtensionName(): void
@@ -72,8 +73,20 @@ final class PackageTest extends TestCase
             [],
             true,
             true,
+            null,
         );
 
         self::assertSame($expectedGithubOrgAndRepo, $package->githubOrgAndRepository());
     }
+
+    public function testFromComposerCompletePackageWithBuildPath(): void
+    {
+        $composerCompletePackage = new CompletePackage('vendor/foo', '1.2.3.0', '1.2.3');
+        $composerCompletePackage->setPhpExt(['build-path' => 'some/subdirectory/path/']);
+
+        $package = Package::fromComposerCompletePackage($composerCompletePackage);
+
+        self::assertSame('vendor/foo:1.2.3', $package->prettyNameAndVersion());
+        self::assertSame('some/subdirectory/path/', $package->buildPath);
+    }
 }
diff --git a/test/unit/Downloading/DownloadedPackageTest.php b/test/unit/Downloading/DownloadedPackageTest.php
index 0cba273..2defd5e 100644
--- a/test/unit/Downloading/DownloadedPackageTest.php
+++ b/test/unit/Downloading/DownloadedPackageTest.php
@@ -12,8 +12,11 @@ use Php\Pie\ExtensionType;
 use PHPUnit\Framework\Attributes\CoversClass;
 use PHPUnit\Framework\TestCase;
 
+use function realpath;
 use function uniqid;
 
+use const DIRECTORY_SEPARATOR;
+
 #[CoversClass(DownloadedPackage::class)]
 final class DownloadedPackageTest extends TestCase
 {
@@ -29,6 +32,7 @@ final class DownloadedPackageTest extends TestCase
             [],
             true,
             true,
+            null,
         );
 
         $extractedSourcePath = uniqid('/path/to/downloaded/package', true);
@@ -38,4 +42,27 @@ final class DownloadedPackageTest extends TestCase
         self::assertSame($extractedSourcePath, $downloadedPackage->extractedSourcePath);
         self::assertSame($package, $downloadedPackage->package);
     }
+
+    public function testFromPackageAndExtractedPathWithBuildPath(): void
+    {
+        $package = new Package(
+            $this->createMock(CompletePackage::class),
+            ExtensionType::PhpModule,
+            ExtensionName::normaliseFromString('foo'),
+            'foo/bar',
+            '1.2.3',
+            null,
+            [],
+            true,
+            true,
+            'Downloading',
+        );
+
+        $extractedSourcePath = realpath(__DIR__ . '/../');
+
+        $downloadedPackage = DownloadedPackage::fromPackageAndExtractedPath($package, $extractedSourcePath);
+
+        self::assertSame($extractedSourcePath . DIRECTORY_SEPARATOR . 'Downloading', $downloadedPackage->extractedSourcePath);
+        self::assertSame($package, $downloadedPackage->package);
+    }
 }
diff --git a/test/unit/Downloading/Exception/CouldNotFindReleaseAssetTest.php b/test/unit/Downloading/Exception/CouldNotFindReleaseAssetTest.php
index 5b6a87f..64ba9d0 100644
--- a/test/unit/Downloading/Exception/CouldNotFindReleaseAssetTest.php
+++ b/test/unit/Downloading/Exception/CouldNotFindReleaseAssetTest.php
@@ -32,6 +32,7 @@ final class CouldNotFindReleaseAssetTest extends TestCase
             [],
             true,
             true,
+            null,
         );
 
         $exception = CouldNotFindReleaseAsset::forPackage($package, ['something.zip', 'something2.zip']);
@@ -51,6 +52,7 @@ final class CouldNotFindReleaseAssetTest extends TestCase
             [],
             true,
             true,
+            null,
         );
 
         $exception = CouldNotFindReleaseAsset::forPackageWithMissingTag($package);
diff --git a/test/unit/Downloading/GithubPackageReleaseAssetsTest.php b/test/unit/Downloading/GithubPackageReleaseAssetsTest.php
index 3be25fe..846b507 100644
--- a/test/unit/Downloading/GithubPackageReleaseAssetsTest.php
+++ b/test/unit/Downloading/GithubPackageReleaseAssetsTest.php
@@ -79,6 +79,7 @@ final class GithubPackageReleaseAssetsTest extends TestCase
             [],
             true,
             true,
+            null,
         );
 
         $releaseAssets = new GithubPackageReleaseAssets('https://test-github-api-base-url.thephp.foundation');
@@ -137,6 +138,7 @@ final class GithubPackageReleaseAssetsTest extends TestCase
             [],
             true,
             true,
+            null,
         );
 
         $releaseAssets = new GithubPackageReleaseAssets('https://test-github-api-base-url.thephp.foundation');
@@ -176,6 +178,7 @@ final class GithubPackageReleaseAssetsTest extends TestCase
             [],
             true,
             true,
+            null,
         );
 
         $releaseAssets = new GithubPackageReleaseAssets('https://test-github-api-base-url.thephp.foundation');
diff --git a/test/unit/Platform/WindowsExtensionAssetNameTest.php b/test/unit/Platform/WindowsExtensionAssetNameTest.php
index 7cf3452..8f10040 100644
--- a/test/unit/Platform/WindowsExtensionAssetNameTest.php
+++ b/test/unit/Platform/WindowsExtensionAssetNameTest.php
@@ -50,6 +50,7 @@ final class WindowsExtensionAssetNameTest extends TestCase
             [],
             true,
             true,
+            null,
         );
     }