diff --git a/docs/zh/contributing/index.md b/docs/zh/contributing/index.md index 2b9f2fca..88461ac2 100644 --- a/docs/zh/contributing/index.md +++ b/docs/zh/contributing/index.md @@ -4,45 +4,48 @@ ## 贡献方法 -如果你有代码或文档想要贡献,需要先了解以下内容。 +如果你有代码或文档要贡献,以下是你需要首先了解的内容。 1. 你要贡献什么类型的代码?(新扩展、修复 Bug、安全问题、项目框架优化、文档) 2. 如果你贡献了新文件或新片段,你的代码是否经过 `php-cs-fixer` 和 `phpstan` 的检查? 3. 在贡献代码前是否充分阅读了 [开发指南](../develop/)? -如果你可以回答以上问题,并已经对代码做出了修改,可以及时在项目 GitHub 仓库发起 Pull Request。待代码审查完毕后,可根据建议修改代码,或直接合并到主分支。 +如果你能回答上述问题并对代码进行了修改,可以及时在项目 GitHub 仓库发起 Pull Request。 +代码审查完成后,可以根据建议修改代码,或直接合并到主分支。 ## 贡献类型 -本项目主要用途是编译静态链接的 PHP 二进制,基于 `symfony/console` 编写了命令行处理功能。在开发之前,如果你对它不够熟悉, -可以先查看 [symfony/console 文档](https://symfony.com/doc/current/components/console.html)。 +本项目的主要目的是编译静态链接的 PHP 二进制文件,命令行处理功能基于 `symfony/console` 编写。 +在开发之前,如果你对它不够熟悉,请先查看 [symfony/console 文档](https://symfony.com/doc/current/components/console.html)。 -### 安全问题 +### 安全更新 -因为本项目基本上是属于本地运行的 PHP 项目,一般来说不会存在远程攻击行为。但如果你发现了此类问题,请**不要**在 GitHub 仓库提交 PR 或 Issue, -你需要通过 [邮件](mailto:admin@zhamao.me) 的方式联系项目维护者(crazywhalecc)。 +因为本项目基本上是一个本地运行的 PHP 项目,一般来说不会有远程攻击。 +但如果你发现此类问题,请**不要**在 GitHub 仓库提交 PR 或 Issue, +你需要通过 [邮件](mailto:admin@zhamao.me) 联系项目维护者(crazywhalecc)。 ### 修复 Bug -修复 Bug 一般不涉及项目结构和框架的修改,所以如果你可以定位到错误代码并直接修复它,请直接提交 PR。 +修复 Bug 一般不涉及项目结构和框架的修改,所以如果你能定位到错误代码并直接修复它,请直接提交 PR。 ### 新扩展 -对于添加一个新扩展来说,你需要先了解一些本项目的基本结构,以及如何根据现有的逻辑添加新扩展。在本页的下一章节将会详细介绍。 +对于添加新扩展,你需要了解项目的一些基本结构以及如何根据现有逻辑添加新扩展。 +这将在本页的下一节中详细介绍。 总的来说,你需要: 1. 评估扩展是否可以内联编译到 PHP 中。 2. 评估扩展的依赖库(如果有)是否可以静态编译。 -3. 写出扩展的依赖库在不同平台编译命令。 -4. 验证扩展及其依赖库能否与现有扩展和依赖库兼容。 -5. 验证扩展在 `cli`、`micro`、`fpm`、`embed` 几种 SAPI 中均正常工作。 -6. 编写文档,加入你的扩展。 +3. 编写不同平台的库编译命令。 +4. 验证扩展及其依赖项与现有扩展和依赖项兼容。 +5. 验证扩展在 `cli`、`micro`、`fpm`、`embed` SAPIs 中正常工作。 +6. 编写文档并添加你的扩展。 ### 项目框架优化 如果你已经熟悉 `symfony/console` 的工作原理,并同时要对项目的框架进行一些修改或优化,请先了解以下事情: -1. 加入扩展不属于项目框架优化,但如果你在加入新的扩展时发现不得不优化框架,则需先对框架本身进行修改,然后再加入扩展。 -2. 对于一些大规模逻辑修改(例如涉及 LibraryBase、Extension 对象等的修改)时,建议先提交 Issue 或 Draft PR 进行讨论方案。 -3. 项目早期为纯中文开发项目,代码中存在一部分中文的注释。国际化项目后你可以提交 PR 将这些注释翻译为英语。 -4. 请不要在代码中提交包含较多无用的代码片段,例如大量未被使用的变量、方法、类、重复写了很多次的代码。 +1. 添加扩展不属于项目框架优化,但如果你在添加新扩展时发现必须优化框架,则需要先修改框架本身,然后再添加扩展。 +2. 对于一些大规模逻辑修改(例如涉及 LibraryBase、Extension 对象等的修改),建议先提交 Issue 或 Draft PR 进行讨论。 +3. 在项目早期,它是一个纯私有开发项目,代码中有一些中文注释。项目国际化后,你可以提交 PR 将这些注释翻译为英语。 +4. 请不要在代码中提交更多无用的代码片段,例如大量未使用的变量、方法、类以及多次重写的代码。 diff --git a/docs/zh/develop/index.md b/docs/zh/develop/index.md index 330ca511..85c9ad5f 100644 --- a/docs/zh/develop/index.md +++ b/docs/zh/develop/index.md @@ -2,7 +2,7 @@ 开发本项目需要安装部署 PHP 环境,以及一些 PHP 项目常用的扩展和 Composer。 -项目的开发环境和运行环境几乎完全一致,你可以参照 **指南-本地构建** 部分安装系统 PHP 或使用本项目预构建的静态 PHP 作为环境,这里不再赘述。 +项目的开发环境和运行环境几乎完全一致。你可以参照 **手动构建** 部分安装系统 PHP 或使用本项目预构建的静态 PHP 作为环境。这里不再赘述。 抛开用途,本项目本身其实就是一个 `php-cli` 程序,你可以将它当作一个正常的 PHP 项目进行编辑和开发,同时你需要了解不同系统的 Shell 命令行。 @@ -18,10 +18,10 @@ curl,dom,filter,mbstring,openssl,pcntl,phar,posix,sodium,tokenizer,xml,xmlwriter ``` -static-php-cli 项目本身不需要这么多扩展,但在开发过程中,你会用到 Composer、PHPUnit 等工具,它们需要这些扩展。 +static-php-cli 项目本身不需要这么多扩展,但在开发过程中,你会用到 Composer 和 PHPUnit 等工具,它们需要这些扩展。 > 对于 static-php-cli 自身构建的 micro 自执行二进制,仅需要 `pcntl,posix,mbstring,tokenizer,phar`。 ## 开始开发 -继续向下查看项目结构的文档,你可以从中了解 `static-php-cli` 是如何运作的。 +继续向下查看项目结构文档,你可以学习 `static-php-cli` 是如何工作的。 diff --git a/docs/zh/faq/index.md b/docs/zh/faq/index.md index 4dfbade4..27611a50 100644 --- a/docs/zh/faq/index.md +++ b/docs/zh/faq/index.md @@ -4,28 +4,28 @@ ## php.ini 的路径是什么? -在 Linux、macOS 和 FreeBSD 上,`php.ini` 的默认路径是 `/usr/local/etc/php/php.ini`。 +在 Linux、macOS 和 FreeBSD 上,`php.ini` 的路径是 `/usr/local/etc/php/php.ini`。 在 Windows 中,路径是 `C:\windows\php.ini` 或 `php.exe` 所在的当前目录。 可以在 *nix 系统中使用手动构建选项 `--with-config-file-path` 来更改查找 `php.ini` 的目录。 -此外,在 Linux、macOS 和 FreeBSD 上,`/usr/local/etc/php/conf.d` 目录中的 `*.ini` 文件也会被加载。 +此外,在 Linux、macOS 和 FreeBSD 上,`/usr/local/etc/php/conf.d` 目录中的 `.ini` 文件也会被加载。 在 Windows 中,该路径默认为空。 可以使用手动构建选项 `--with-config-file-scan-dir` 更改该目录。 PHP 默认也会从 [其他标准位置](https://www.php.net/manual/zh/configuration.file.php) 中搜索 `php.ini`。 -## 静态编译的 PHP 可以安装扩展吗 +## 静态编译的 PHP 可以安装扩展吗? 因为传统架构下的 PHP 安装扩展的原理是使用 `.so` 类型的动态链接的库方式安装新扩展,而使用本项目编译的静态链接的 PHP。但是静态链接在不同操作系统有不同的定义。 -首先对于 Linux 系统来说,静态链接的二进制文件是不会链接系统的动态链接库的,纯静态链接的二进制无法加载动态库,所以无法添加新的扩展。 -同时,在纯静态模式下你也不能使用 `ffi` 等扩展加载外部的 `.so` 模块。 +首先,对于 Linux 系统,静态链接的二进制文件不会链接系统的动态链接库。纯静态链接的二进制文件(`-all-static`)无法加载动态库,因此无法添加新扩展。 +同时,在纯静态模式下,你也不能使用 `ffi` 等扩展来加载外部 `.so` 模块。 -你可以通过命令 `ldd buildroot/bin/php` 来查看你在 Linux 下构建的二进制是否为纯静态链接的。 +你可以使用命令 `ldd buildroot/bin/php` 来检查你在 Linux 下构建的二进制文件是否为纯静态链接。 -如果你 [构建 GNU libc 兼容的 PHP](../guide/build-with-glibc),你可以使用 `ffi` 扩展加载外部的 `.so` 模块,并且加载具有相同 ABI 的 `.so` 扩展。 +如果你 [构建基于 GNU libc 的 PHP](../guide/build-with-glibc),你可以使用 `ffi` 扩展来加载外部 `.so` 模块,并加载具有相同 ABI 的 `.so` 扩展。 -例如,你可以使用以下命令构建一个 glibc 动态链接的静态 PHP 二进制,同时支持 FFI 扩展和加载相同 PHP 版本和相同 TS 类型的 `xdebug.so` 扩展: +例如,你可以使用以下命令构建一个与 glibc 动态链接的静态 PHP 二进制文件,支持 FFI 扩展并加载相同 PHP 版本和相同 TS 类型的 `xdebug.so` 扩展: ```bash bin/spc-gnu-docker download --for-extensions=ffi,xml --with-php=8.4 @@ -34,14 +34,14 @@ bin/spc-gnu-docker build ffi,xml --build-cli --debug buildroot/bin/php -d "zend_extension=/path/to/php{PHP_VER}-{ts/nts}/xdebug.so" --ri xdebug ``` -对于 macOS 平台来说,macOS 下的几乎所有二进制文件都无法真正纯静态链接,几乎所有二进制文件都会链接 macOS 的系统库:`/usr/lib/libresolv.9.dylib` 和 `/usr/lib/libSystem.B.dylib`。 -因此,在 macOS 上,您可以直接构建出使用静态编译的 PHP 二进制文件和动态链接的扩展: +对于 macOS 平台,macOS 下的几乎所有二进制文件都无法真正纯静态链接,几乎所有二进制文件都会链接 macOS 系统库:`/usr/lib/libresolv.9.dylib` 和 `/usr/lib/libSystem.B.dylib`。 +因此,在 macOS 上,你可以**直接**使用 SPC 构建具有动态链接扩展的静态编译 PHP 二进制文件: 1. 使用 `--build-shared=XXX` 选项构建共享扩展 `xxx.so`。例如:`bin/spc build bcmath,zlib --build-shared=xdebug --build-cli` -2. 您将获得 `buildroot/modules/xdebug.so` 和 `buildroot/bin/php`。 +2. 你将获得 `buildroot/modules/xdebug.so` 和 `buildroot/bin/php`。 3. `xdebug.so` 文件可用于版本和线程安全相同的 php。 -## 可以支持 Oracle 数据库扩展吗 +## 可以支持 Oracle 数据库扩展吗? 部分依赖库闭源的扩展,如 `oci8`、`sourceguardian` 等,它们没有提供纯静态编译的依赖库文件(`.a`),仅提供了动态依赖库文件(`.so`), 这些扩展无法使用源码的形式编译到 static-php-cli 中,所以本项目可能永远也不会支持这些扩展。不过,理论上你可以根据上面的问题在 macOS 和 Linux 下接入和使用这类扩展。 @@ -49,41 +49,43 @@ buildroot/bin/php -d "zend_extension=/path/to/php{PHP_VER}-{ts/nts}/xdebug.so" - 如果你对此类扩展有需求,或者大部分人都对这些闭源扩展使用有需求, 可以看看有关 [standalone-php-cli](https://github.com/crazywhalecc/static-php-cli/discussions/58) 的讨论。欢迎留言。 -## 支持 Windows 吗 +## 支持 Windows 吗? -该项目目前已支持 Windows,但支持的扩展数量较少,Windows 的支持并不完美,主要有以下几个问题: +该项目目前支持 Windows,但支持的扩展数量较少。Windows 支持并不完美。主要有以下问题: -1. Windows 的编译流程与 *nix 不同,使用的工具链也不同,编译各个扩展的依赖库使用的编译工具也几乎完全不同。 -2. Windows 版本的需求也会根据所有使用本项目的人的需求推进,如果有很多人需要,我会尽快支持相关扩展。 +1. Windows 的编译过程与 *nix 不同,使用的工具链也不同。用于编译每个扩展依赖库的编译工具也几乎完全不同。 +2. Windows 版本的需求也会根据所有使用本项目的人的需求推进。如果很多人需要,我会尽快支持相关扩展。 -## 使用 micro 可以保护我的源码吗 +## 我可以使用 micro 保护我的源代码吗? -不可以。micro.sfx 本质上是将 php 和 php 代码结合为一个文件,没有 PHP 代码编译或加密的过程。 -首先 php-src 是 PHP 代码的官方解释器,而且现在市面上还没有一个能兼容主流分支的 PHP 编译器。 -之前我在网上看到有一个项目是 BPC(Binary PHP Compiler?)可以把 PHP 编译为二进制,但是限制也是很多很多。 +不可以。micro.sfx 本质上是将 php 和 php 代码合并为一个文件,没有编译或加密 PHP 代码的过程。 -加密保护代码的方向和编译也不是一回事,编译过后也可以通过逆向工程等方式拿到代码,真正保护还是通过加壳、加密代码等手段进行。 +首先,php-src 是 PHP 代码的官方解释器,市场上没有与主流分支兼容的 PHP 编译器。 +我在网上看到一个名为 BPC(Binary PHP Compiler?)的项目可以将 PHP 编译为二进制,但有很多限制。 -所以本项目(static-php-cli)、相关项目(lwmbs、swoole-cli)都是提供一个对 php-src 源码的便捷编译工具, -本项目和相关项目引用的 phpmicro 也仅仅是 PHP 的 sapi 接口封装,而不是 PHP 代码的编译工具。 -PHP 代码的编译器是完全不同的项目,因此不会考虑额外的情况。如果你对加密感兴趣,可以考虑使用现有的加密技术,如 Swoole Compiler、Source Guardian 等。 +加密和保护代码的方向与编译不同。编译后,也可以通过逆向工程等方法获得代码。真正的保护仍然通过打包和加密代码等手段进行。 + +因此,本项目(static-php-cli)和相关项目(lwmbs、swoole-cli)都提供了 php-src 源代码的便捷编译工具。 +本项目和相关项目引用的 phpmicro 只是 PHP 的 sapi 接口封装,而不是 PHP 代码的编译工具。 +PHP 代码的编译器是一个完全不同的项目,因此不考虑额外的情况。 +如果你对加密感兴趣,可以考虑使用现有的加密技术,如 Swoole Compiler、Source Guardian 等。 ## 无法使用 ssl -**更新:该问题已在最新版本的 static-php-cli 中修复,现在默认读取系统的证书文件。如果你仍然遇到问题,再尝试下方的解决方案。** +**更新:该问题已在最新版本的 static-php-cli 中修复,现在默认读取系统的证书文件。如果你仍然遇到问题,请尝试下面的解决方案。** -使用 curl、pgsql 等 请求 HTTPS 网站或建立 SSL 连接时,可能存在 `error:80000002:system library::No such file or directory` 错误, -这个错误是由于静态编译的 PHP 未通过 `php.ini` 指定 `openssl.cafile` 导致的。 +使用 curl、pgsql 等请求 HTTPS 网站或建立 SSL 连接时,可能会出现 `error:80000002:system library::No such file or directory` 错误。 +此错误是由于静态编译的 PHP 未通过 `php.ini` 指定 `openssl.cafile` 导致的。 -你可以在使用 PHP 前指定 `php.ini`,并在 INI 内添加 `openssl.cafile=/path/to/your-cert.pem` 来解决这个问题。 +你可以通过在使用 PHP 前指定 `php.ini` 并在 INI 中添加 `openssl.cafile=/path/to/your-cert.pem` 来解决此问题。 对于 Linux 系统,你可以从 curl 官方网站下载 [cacert.pem](https://curl.se/docs/caextract.html) 文件,也可以使用系统自带的证书文件。 -有关不同发行版的证书位置,可参考 [Go 标准库](https://go.dev/src/crypto/x509/root_linux.go)。 +有关不同发行版的证书位置,请参考 [Golang 文档](https://go.dev/src/crypto/x509/root_linux.go)。 -> INI 配置 `openssl.cafile` 不可以使用 `ini_set()` 函数动态设置,因为 `openssl.cafile` 是一个 `PHP_INI_SYSTEM` 类型的配置,只能在 `php.ini` 文件中设置。 +> INI 配置 `openssl.cafile` 不能使用 `ini_set()` 函数动态设置,因为 `openssl.cafile` 是 `PHP_INI_SYSTEM` 类型的配置,只能在 `php.ini` 文件中设置。 -## 为什么不支持旧版本 PHP ? +## 为什么不支持旧版本的 PHP? -因为旧版本的 PHP 有很多问题,比如安全问题、性能问题、功能问题等。此外,旧版本的 PHP 很多都无法与最新的依赖库兼容,这也是不支持旧版本 PHP 的原因之一。 +因为旧版本的 PHP 有很多问题,如安全问题、性能问题和功能问题。此外,许多旧版本的 PHP 与最新的依赖库不兼容,这也是不支持旧版本 PHP 的原因之一。 -你可以使用 static-php-cli 早期编译好的旧版本,如 PHP 8.0,但是不会明确支持早期版本。 +你可以使用 static-php-cli 早期编译的旧版本,如 PHP 8.0,但不会明确支持早期版本。 diff --git a/docs/zh/guide/action-build.md b/docs/zh/guide/action-build.md index d14deb81..11f382d5 100644 --- a/docs/zh/guide/action-build.md +++ b/docs/zh/guide/action-build.md @@ -5,6 +5,7 @@ Action 构建指的是直接使用 GitHub Action 进行编译。 如果你不想自行编译,可以从本项目现有的 Action 下载 Artifact,也可以从自托管的服务器下载:[进入](https://dl.static-php.dev/static-php-cli/common/) > 自托管的二进制也是由 Action 构建而来,[项目仓库地址](https://github.com/static-php/static-php-cli-hosted)。 +> 包含的扩展有:bcmath,bz2,calendar,ctype,curl,dom,exif,fileinfo,filter,ftp,gd,gmp,iconv,xml,mbstring,mbregex,mysqlnd,openssl,pcntl,pdo,pdo_mysql,pdo_sqlite,phar,posix,redis,session,simplexml,soap,sockets,sqlite3,tokenizer,xmlwriter,xmlreader,zlib,zip ## 构建方法 diff --git a/docs/zh/guide/index.md b/docs/zh/guide/index.md index eb0674c3..33192360 100644 --- a/docs/zh/guide/index.md +++ b/docs/zh/guide/index.md @@ -4,8 +4,8 @@ static-php-cli 是一个用于构建静态编译的 PHP 二进制的工具,目 在指南章节中,你将了解到如何使用 static-php-cli 构建独立的 php 程序。 -- [Action 构建](./action-build) - [本地构建](./manual-build) +- [Action 构建](./action-build) - [扩展列表](./extensions) ## 编译环境 diff --git a/docs/zh/guide/troubleshooting.md b/docs/zh/guide/troubleshooting.md index f56f2ac4..c6523654 100644 --- a/docs/zh/guide/troubleshooting.md +++ b/docs/zh/guide/troubleshooting.md @@ -10,20 +10,22 @@ 当下载资源时,你可能最终会看到类似 `curl: (56) The requested URL returned error: 403` 的错误,这通常是由于 GitHub 限制导致的。 你可以通过在命令中添加 `--debug` 来验证,会看到类似 `[DEBU] Running command (no output) : curl -sfSL "https://api.github.com/repos/openssl/openssl/releases"` 的输出。 -要解决这个问题,可以在 GitHub 上 [创建](https://github.com/settings/token) 一个个人访问令牌,并将其设置为环境变量 `GITHUB_TOKEN=`。 +要解决这个问题,可以在 GitHub 上 [创建](https://github.com/settings/tokens) 一个个人访问令牌,并将其设置为环境变量 `GITHUB_TOKEN=`。 -如果确认地址确实无法正常访问,可以提交 Issue 或 PR 更新地址。 +如果确认地址确实无法正常访问,可以提交 Issue 或 PR 更新地址或下载类型。 -## doctor 无法修复 +## Doctor 无法修复某些问题 在绝大部分情况下,doctor 模块都可以对缺失的系统环境进行自动修复和安装,但也存在特殊的环境无法正常使用自动修复功能。 -部分项目由于系统局限(如 Windows 下无法自动安装 Visual Studio 等软件),无法使用自动修复功能。 -在遇到无法自动修复功能时,如果遇到 `Some check items can not be fixed` 字样,则表明无法自动修复,请根据终端显示的方法提交 Issue 或自行修复环境。 +由于系统限制(例如,Windows 下无法自动安装 Visual Studio 等软件),自动修复功能无法用于某些项目。 +在遇到无法自动修复功能时,如果遇到 `Some check items can not be fixed` 字样,则表明无法自动修复。 +请根据终端显示的方法提交 Issue 或自行修复环境。 ## 编译错误 遇到编译错误时,如果没有开启 `--debug` 日志,请先开启调试日志,然后确定报错的命令。 -报错的终端输出对于修复编译错误非常重要,请在提交 Issue 时一并将终端日志的最后报错片段(或整个终端日志输出)上传,并且包含使用的 `spc` 命令和参数。 +报错的终端输出对于修复编译错误非常重要。 +在提交 Issue 时,请上传终端日志的最后报错片段(或整个终端日志输出),并且包含使用的 `spc` 命令和参数。 -如果你是重复构建,请参考 [本地构建 - 多次构建](./manual-build#多次构建) 章节,清理构建缓存后再次构建。 +如果你是重复构建,请参考 [本地构建 - 多次构建](./manual-build#多次构建) 章节。 diff --git a/src/SPC/builder/LibraryInterface.php b/src/SPC/builder/LibraryInterface.php index da8ba75a..5368555e 100644 --- a/src/SPC/builder/LibraryInterface.php +++ b/src/SPC/builder/LibraryInterface.php @@ -4,7 +4,18 @@ declare(strict_types=1); namespace SPC\builder; +/** + * Interface for library implementations + * + * This interface defines the basic contract that all library classes must implement. + * It provides a common way to identify and work with different library types. + */ interface LibraryInterface { + /** + * Get the name of the library + * + * @return string The library name + */ public function getName(): string; } diff --git a/src/SPC/builder/linux/LinuxBuilder.php b/src/SPC/builder/linux/LinuxBuilder.php index 7628f667..85cda2a1 100644 --- a/src/SPC/builder/linux/LinuxBuilder.php +++ b/src/SPC/builder/linux/LinuxBuilder.php @@ -265,7 +265,7 @@ class LinuxBuilder extends UnixBuilderBase ->exec('sed -i "s|^EXTENSION_DIR = .*|EXTENSION_DIR = /' . basename(BUILD_MODULES_PATH) . '|" Makefile') ->exec(getenv('SPC_CMD_PREFIX_PHP_MAKE') . ' INSTALL_ROOT=' . BUILD_ROOT_PATH . " {$vars} install"); - $ldflags = getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS'); + $ldflags = getenv('SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS') ?: ''; if (preg_match('/-release\s+(\S+)/', $ldflags, $matches)) { $release = $matches[1]; $realLibName = 'libphp-' . $release . '.so'; diff --git a/src/SPC/store/Config.php b/src/SPC/store/Config.php index 16f925c6..3ac6abb2 100644 --- a/src/SPC/store/Config.php +++ b/src/SPC/store/Config.php @@ -7,9 +7,6 @@ namespace SPC\store; use SPC\exception\FileSystemException; use SPC\exception\WrongUsageException; -/** - * 一个读取 config 配置的操作类 - */ class Config { public static ?array $pkg = null; @@ -23,6 +20,10 @@ class Config public static ?array $pre_built = null; /** + * Get pre-built configuration by name + * + * @param string $name The name of the pre-built configuration + * @return mixed The pre-built configuration or null if not found * @throws WrongUsageException * @throws FileSystemException */ @@ -50,8 +51,10 @@ class Config } /** - * 从配置文件读取一个资源(source)的元信息 + * Get source configuration by name * + * @param string $name The name of the source + * @return null|array The source configuration or null if not found * @throws FileSystemException */ public static function getSource(string $name): ?array @@ -63,8 +66,10 @@ class Config } /** - * Read pkg from pkg.json + * Get package configuration by name * + * @param string $name The name of the package + * @return null|array The package configuration or null if not found * @throws FileSystemException */ public static function getPkg(string $name): ?array @@ -76,11 +81,13 @@ class Config } /** - * 根据不同的操作系统分别选择不同的 lib 库依赖项 - * 如果 key 为 null,那么直接返回整个 meta。 - * 如果 key 不为 null,则可以使用的 key 有 static-libs、headers、lib-depends、lib-suggests。 - * 对于 macOS 平台,支持 frameworks。 + * Get library configuration by name and optional key + * Supports platform-specific configurations for different operating systems * + * @param string $name The name of the library + * @param null|string $key The configuration key (static-libs, headers, lib-depends, lib-suggests, frameworks, bin) + * @param mixed $default Default value if key not found + * @return mixed The library configuration or default value * @throws FileSystemException * @throws WrongUsageException */ @@ -115,6 +122,9 @@ class Config } /** + * Get all library configurations + * + * @return array All library configurations * @throws FileSystemException */ public static function getLibs(): array @@ -126,6 +136,10 @@ class Config } /** + * Get extension target configuration by name + * + * @param string $name The name of the extension + * @return null|array The extension target configuration or default ['static', 'shared'] * @throws WrongUsageException * @throws FileSystemException */ @@ -141,6 +155,13 @@ class Config } /** + * Get extension configuration by name and optional key + * Supports platform-specific configurations for different operating systems + * + * @param string $name The name of the extension + * @param null|string $key The configuration key (lib-depends, lib-suggests, ext-depends, ext-suggests, arg-type) + * @param mixed $default Default value if key not found + * @return mixed The extension configuration or default value * @throws FileSystemException * @throws WrongUsageException */ @@ -175,6 +196,9 @@ class Config } /** + * Get all extension configurations + * + * @return array All extension configurations * @throws FileSystemException */ public static function getExts(): array @@ -186,6 +210,9 @@ class Config } /** + * Get all source configurations + * + * @return array All source configurations * @throws FileSystemException */ public static function getSources(): array diff --git a/src/SPC/store/Downloader.php b/src/SPC/store/Downloader.php index c9b71c86..edf96396 100644 --- a/src/SPC/store/Downloader.php +++ b/src/SPC/store/Downloader.php @@ -18,12 +18,13 @@ use SPC\util\SPCTarget; class Downloader { /** - * Get latest version from BitBucket tag (type = bitbuckettag) + * Get latest version from BitBucket tag * - * @param string $name source name - * @param array $source source meta info: [repo] + * @param string $name Source name + * @param array $source Source meta info: [repo] * @return array [url, filename] * @throws DownloaderException + * @throws RuntimeException */ public static function getLatestBitbucketTag(string $name, array $source): array { @@ -53,13 +54,12 @@ class Downloader } /** - * Get latest version from GitHub tarball (type = ghtar / ghtagtar) - * - * @param string $name source name - * @param array $source source meta info: [repo] - * @param string $type type of tarball, default is 'releases' - * @return array [url, filename] + * Get latest version from GitHub tarball * + * @param string $name Source name + * @param array $source Source meta info: [repo] + * @param string $type Type of tarball, default is 'releases' + * @return array [url, filename] * @throws DownloaderException */ public static function getLatestGithubTarball(string $name, array $source, string $type = 'releases'): array @@ -107,8 +107,8 @@ class Downloader /** * Get latest version from GitHub release (uploaded archive) * - * @param string $name source name - * @param array $source source meta info: [repo, match] + * @param string $name Source name + * @param array $source Source meta info: [repo, match] * @param bool $match_result Whether to return matched result by `match` param (default: true) * @return array When $match_result = true, and we matched, [url, filename]. Otherwise, [{asset object}. ...] * @throws DownloaderException @@ -150,8 +150,8 @@ class Downloader /** * Get latest version from file list (regex based crawler) * - * @param string $name source name - * @param array $source source meta info: [url, regex] + * @param string $name Source name + * @param array $source Source meta info: [filelist] * @return array [url, filename] * @throws DownloaderException */ @@ -187,11 +187,17 @@ class Downloader } /** - * Just download file using system curl command, and lock it + * Download file from URL * - * @throws FileSystemException + * @param string $name Download name + * @param string $url Download URL + * @param string $filename Target filename + * @param null|string $move_path Optional move path after download + * @param int $download_as Download type constant + * @param array $headers Optional HTTP headers + * @param array $hooks Optional curl hooks + * @throws DownloaderException * @throws RuntimeException - * @throws WrongUsageException */ public static function downloadFile(string $name, string $url, string $filename, ?string $move_path = null, int $download_as = SPC_DOWNLOAD_SOURCE, array $headers = [], array $hooks = []): void { @@ -213,11 +219,17 @@ class Downloader } /** - * Download git source, and lock it. + * Download Git repository * - * @throws FileSystemException + * @param string $name Repository name + * @param string $url Git repository URL + * @param string $branch Branch to checkout + * @param null|array $submodules Optional submodules to initialize + * @param null|string $move_path Optional move path after download + * @param int $retries Number of retry attempts + * @param int $lock_as Lock type constant + * @throws DownloaderException * @throws RuntimeException - * @throws WrongUsageException */ public static function downloadGit(string $name, string $url, string $branch, ?array $submodules = null, ?string $move_path = null, int $retries = 0, int $lock_as = SPC_DOWNLOAD_SOURCE): void { @@ -304,7 +316,6 @@ class Downloader if ($pkg === null) { $pkg = Config::getPkg($name); } - if ($pkg === null) { logger()->warning('Package {name} unknown. Skipping.', ['name' => $name]); return; @@ -398,7 +409,7 @@ class Downloader } /** - * Download source by name and meta. + * Download source * * @param string $name source name * @param null|array{ @@ -428,7 +439,6 @@ class Downloader if ($source === null) { $source = Config::getSource($name); } - if ($source === null) { logger()->warning('Source {name} unknown. Skipping.', ['name' => $name]); return; @@ -522,7 +532,14 @@ class Downloader /** * Use curl command to get http response * + * @param string $url Target URL + * @param string $method HTTP method (GET, POST, etc.) + * @param array $headers HTTP headers + * @param array $hooks Curl hooks + * @param int $retries Number of retry attempts + * @return string Response body * @throws DownloaderException + * @throws RuntimeException */ public static function curlExec(string $url, string $method = 'GET', array $headers = [], array $hooks = [], int $retries = 0): string { @@ -574,6 +591,13 @@ class Downloader /** * Use curl to download sources from url * + * @param string $url Download URL + * @param string $path Target file path + * @param string $method HTTP method + * @param array $headers HTTP headers + * @param array $hooks Curl hooks + * @param int $retries Number of retry attempts + * @throws DownloaderException * @throws RuntimeException * @throws WrongUsageException */ @@ -603,6 +627,12 @@ class Downloader } } + /** + * Get pre-built lock name from source + * + * @param string $source Source name + * @return string Lock name + */ public static function getPreBuiltLockName(string $source): string { $os_family = PHP_OS_FAMILY; @@ -613,6 +643,12 @@ class Downloader return "{$source}-{$os_family}-{$gnu_arch}-{$libc}-{$libc_version}"; } + /** + * Get default alternative source + * + * @param string $source_name Source name + * @return array Alternative source configuration + */ public static function getDefaultAlternativeSource(string $source_name): array { return [ diff --git a/src/SPC/store/FileSystem.php b/src/SPC/store/FileSystem.php index d67ed6cf..f0d54a54 100644 --- a/src/SPC/store/FileSystem.php +++ b/src/SPC/store/FileSystem.php @@ -12,6 +12,11 @@ class FileSystem private static array $_extract_hook = []; /** + * Load configuration array from JSON file + * + * @param string $config The configuration name (ext, lib, source, pkg, pre-built) + * @param null|string $config_dir Optional custom config directory + * @return array The loaded configuration array * @throws FileSystemException */ public static function loadConfigArray(string $config, ?string $config_dir = null): array @@ -37,9 +42,10 @@ class FileSystem } /** - * 读取文件,读不出来直接抛出异常 + * Read file contents and throw exception on failure * - * @param string $filename 文件路径 + * @param string $filename The file path to read + * @return string The file contents * @throws FileSystemException */ public static function readFile(string $filename): string @@ -53,6 +59,12 @@ class FileSystem } /** + * Replace string content in file + * + * @param string $filename The file path + * @param mixed $search The search string + * @param mixed $replace The replacement string + * @return false|int Number of replacements or false on failure * @throws FileSystemException */ public static function replaceFileStr(string $filename, mixed $search = null, mixed $replace = null): false|int @@ -61,6 +73,12 @@ class FileSystem } /** + * Replace content in file using regex + * + * @param string $filename The file path + * @param mixed $search The regex pattern + * @param mixed $replace The replacement string + * @return false|int Number of replacements or false on failure * @throws FileSystemException */ public static function replaceFileRegex(string $filename, mixed $search = null, mixed $replace = null): false|int @@ -69,6 +87,11 @@ class FileSystem } /** + * Replace content in file using custom callback + * + * @param string $filename The file path + * @param mixed $callback The callback function + * @return false|int Number of replacements or false on failure * @throws FileSystemException */ public static function replaceFileUser(string $filename, mixed $callback = null): false|int @@ -77,9 +100,10 @@ class FileSystem } /** - * 获取文件后缀 + * Get file extension from filename * - * @param string $fn 文件名 + * @param string $fn The filename + * @return string The file extension (without dot) */ public static function extname(string $fn): string { @@ -91,10 +115,11 @@ class FileSystem } /** - * 寻找命令的真实路径,效果类似 which + * Find command path in system PATH (similar to which command) * - * @param string $name 命令名称 - * @param array $paths 路径列表,如果为空则默认从 PATH 系统变量搜索 + * @param string $name The command name + * @param array $paths Optional array of paths to search + * @return null|string The full path to the command or null if not found */ public static function findCommandPath(string $name, array $paths = []): ?string { @@ -120,6 +145,10 @@ class FileSystem } /** + * Copy directory recursively + * + * @param string $from Source directory path + * @param string $to Destination directory path * @throws RuntimeException */ public static function copyDir(string $from, string $to): void @@ -139,6 +168,12 @@ class FileSystem } /** + * Extract package archive to specified directory + * + * @param string $name Package name + * @param string $source_type Archive type (tar.gz, zip, etc.) + * @param string $filename Archive filename + * @param null|string $extract_path Optional extraction path * @throws RuntimeException * @throws FileSystemException */ @@ -171,10 +206,12 @@ class FileSystem } /** - * 解压缩下载的资源包到 source 目录 + * Extract source archive to source directory * - * @param string $name 资源名 - * @param string $filename 文件名 + * @param string $name Source name + * @param string $source_type Archive type (tar.gz, zip, etc.) + * @param string $filename Archive filename + * @param null|string $move_path Optional move path * @throws FileSystemException * @throws RuntimeException */ @@ -207,9 +244,10 @@ class FileSystem } /** - * 根据系统环境的不同,自动转换路径的分隔符 + * Convert path to system-specific format * - * @param string $path 路径 + * @param string $path The path to convert + * @return string The converted path */ public static function convertPath(string $path): string { @@ -219,6 +257,12 @@ class FileSystem return str_replace('/', DIRECTORY_SEPARATOR, $path); } + /** + * Convert Windows path to MinGW format + * + * @param string $path The Windows path + * @return string The MinGW format path + */ public static function convertWinPathToMinGW(string $path): string { if (preg_match('/^[A-Za-z]:/', $path)) { @@ -228,28 +272,21 @@ class FileSystem } /** - * 递归或非递归扫描目录,可返回相对目录的文件列表或绝对目录的文件列表 + * Scan directory files recursively * - * @param string $dir 目录 - * @param bool $recursive 是否递归扫描子目录 - * @param bool|string $relative 是否返回相对目录,如果为true则返回相对目录,如果为false则返回绝对目录 - * @param bool $include_dir 非递归模式下,是否包含目录 - * @since 2.5 + * @param string $dir Directory to scan + * @param bool $recursive Whether to scan recursively + * @param bool|string $relative Whether to return relative paths + * @param bool $include_dir Whether to include directories in result + * @return array|false Array of files or false on failure */ public static function scanDirFiles(string $dir, bool $recursive = true, bool|string $relative = false, bool $include_dir = false): array|false { $dir = self::convertPath($dir); - // 不是目录不扫,直接 false 处理 - if (!file_exists($dir)) { - logger()->debug('Scan dir failed, no such file or directory.'); - return false; - } if (!is_dir($dir)) { - logger()->warning('Scan dir failed, not directory.'); return false; } logger()->debug('scanning directory ' . $dir); - // 套上 zm_dir $scan_list = scandir($dir); if ($scan_list === false) { logger()->warning('Scan dir failed, cannot scan directory: ' . $dir); @@ -283,18 +320,17 @@ class FileSystem } /** - * 获取该路径下的所有类名,根据 psr-4 方式 + * Get PSR-4 classes from directory * - * @param string $dir 目录 - * @param string $base_namespace 基类命名空间 - * @param null|mixed $rule 规则回调 - * @param bool|string $return_path_value 是否返回路径对应的数组,默认只返回类名列表 - * @throws FileSystemException + * @param string $dir Directory to scan + * @param string $base_namespace Base namespace + * @param mixed $rule Optional filtering rule + * @param bool|string $return_path_value Whether to return path as value + * @return array Array of class names or class=>path pairs */ public static function getClassesPsr4(string $dir, string $base_namespace, mixed $rule = null, bool|string $return_path_value = false): array { $classes = []; - // 扫描目录,使用递归模式,相对路径模式,因为下面此路径要用作转换成namespace $files = FileSystem::scanDirFiles($dir, true, true); if ($files === false) { throw new FileSystemException('Cannot scan dir files during get classes psr-4 from dir: ' . $dir); @@ -325,15 +361,15 @@ class FileSystem } /** - * 删除目录及目录下的所有文件(危险操作) + * Remove directory recursively * - * @throws FileSystemException + * @param string $dir Directory to remove + * @return bool Success status */ public static function removeDir(string $dir): bool { $dir = FileSystem::convertPath($dir); logger()->debug('Removing path recursively: "' . $dir . '"'); - // 不是目录不扫,直接 false 处理 if (!file_exists($dir)) { logger()->debug('Scan dir failed, no such file or directory.'); return false; @@ -374,6 +410,9 @@ class FileSystem } /** + * Create directory recursively + * + * @param string $path Directory path to create * @throws FileSystemException */ public static function createDir(string $path): void @@ -384,7 +423,12 @@ class FileSystem } /** - * @param mixed ...$args Arguments passed to file_put_contents + * Write content to file + * + * @param string $path File path + * @param mixed $content Content to write + * @param mixed ...$args Additional arguments passed to file_put_contents + * @return bool|int|string Result of file writing operation * @throws FileSystemException */ public static function writeFile(string $path, mixed $content, ...$args): bool|int|string @@ -397,27 +441,36 @@ class FileSystem } /** - * Reset (remove recursively and create again) dir + * Reset directory by removing and recreating it * + * @param string $dir_name Directory name * @throws FileSystemException */ public static function resetDir(string $dir_name): void { + $dir_name = self::convertPath($dir_name); if (is_dir($dir_name)) { self::removeDir($dir_name); } self::createDir($dir_name); } + /** + * Add source extraction hook + * + * @param string $name Source name + * @param callable $callback Callback function + */ public static function addSourceExtractHook(string $name, callable $callback): void { self::$_extract_hook[$name][] = $callback; } /** - * Check whether the path is a relative path (judging according to whether the first character is "/") + * Check if path is relative * - * @param string $path Path + * @param string $path Path to check + * @return bool True if path is relative */ public static function isRelativePath(string $path): bool { @@ -427,6 +480,12 @@ class FileSystem return strlen($path) > 0 && $path[0] !== '/'; } + /** + * Replace path variables with actual values + * + * @param string $path Path with variables + * @return string Path with replaced variables + */ public static function replacePathVariable(string $path): string { $replacement = [ @@ -439,12 +498,23 @@ class FileSystem return str_replace(array_keys($replacement), array_values($replacement), $path); } + /** + * Create backup of file + * + * @param string $path File path + * @return string Backup file path + */ public static function backupFile(string $path): string { copy($path, $path . '.bak'); return $path . '.bak'; } + /** + * Restore file from backup + * + * @param string $path Original file path + */ public static function restoreBackupFile(string $path): void { if (!file_exists($path . '.bak')) { @@ -454,14 +524,26 @@ class FileSystem unlink($path . '.bak'); } + /** + * Remove file if it exists + * + * @param string $string File path + */ public static function removeFileIfExists(string $string): void { + $string = self::convertPath($string); if (file_exists($string)) { unlink($string); } } /** + * Replace line in file that contains specific string + * + * @param string $file File path + * @param string $find String to find in line + * @param string $line New line content + * @return false|int Number of replacements or false on failure * @throws FileSystemException */ public static function replaceFileLineContainsString(string $file, string $find, string $line): false|int diff --git a/src/SPC/store/pkg/CustomPackage.php b/src/SPC/store/pkg/CustomPackage.php index 89edb17e..7e5c3532 100644 --- a/src/SPC/store/pkg/CustomPackage.php +++ b/src/SPC/store/pkg/CustomPackage.php @@ -4,12 +4,36 @@ declare(strict_types=1); namespace SPC\store\pkg; +/** + * Abstract base class for custom package implementations + * + * This class provides a framework for implementing custom package download + * and extraction logic. Extend this class to create custom package handlers. + */ abstract class CustomPackage { + /** + * Get the list of package names supported by this implementation + * + * @return array Array of supported package names + */ abstract public function getSupportName(): array; + /** + * Fetch the package from its source + * + * @param string $name Package name + * @param bool $force Force download even if already exists + * @param null|array $config Optional configuration array + */ abstract public function fetch(string $name, bool $force = false, ?array $config = null): void; + /** + * Extract the downloaded package + * + * @param string $name Package name + * @throws \RuntimeException If extraction is not implemented + */ public function extract(string $name): void { throw new \RuntimeException("Extract method not implemented for package: {$name}"); diff --git a/src/SPC/store/source/CustomSourceBase.php b/src/SPC/store/source/CustomSourceBase.php index 3fce1568..2f02fa7d 100644 --- a/src/SPC/store/source/CustomSourceBase.php +++ b/src/SPC/store/source/CustomSourceBase.php @@ -4,9 +4,25 @@ declare(strict_types=1); namespace SPC\store\source; +/** + * Abstract base class for custom source implementations + * + * This class provides a framework for implementing custom source download + * logic. Extend this class to create custom source handlers. + */ abstract class CustomSourceBase { + /** + * The name of this source implementation + */ public const NAME = 'unknown'; + /** + * Fetch the source from its repository + * + * @param bool $force Force download even if already exists + * @param null|array $config Optional configuration array + * @param int $lock_as Lock type constant + */ abstract public function fetch(bool $force = false, ?array $config = null, int $lock_as = SPC_DOWNLOAD_SOURCE): void; } diff --git a/src/SPC/toolchain/ToolchainInterface.php b/src/SPC/toolchain/ToolchainInterface.php index 5c6d51d8..62cf2b2d 100644 --- a/src/SPC/toolchain/ToolchainInterface.php +++ b/src/SPC/toolchain/ToolchainInterface.php @@ -4,15 +4,27 @@ declare(strict_types=1); namespace SPC\toolchain; +/** + * Interface for toolchain implementations + * + * This interface defines the contract for toolchain classes that handle + * environment initialization and setup for different build targets. + */ interface ToolchainInterface { /** * Initialize the environment for the given target. + * + * This method should set up any necessary environment variables, + * paths, or configurations required for the build process. */ public function initEnv(): void; /** * Perform actions after the environment has been initialized for the given target. + * + * This method is called after initEnv() and can be used for any + * post-initialization setup or validation. */ public function afterInit(): void; } diff --git a/src/SPC/util/ConfigValidator.php b/src/SPC/util/ConfigValidator.php index b908efce..cfb9c2c2 100644 --- a/src/SPC/util/ConfigValidator.php +++ b/src/SPC/util/ConfigValidator.php @@ -20,33 +20,48 @@ class ConfigValidator public static function validateSource(array $data): void { foreach ($data as $name => $src) { - isset($src['type']) || throw new ValidationException("source {$name} must have prop: [type]"); - is_string($src['type']) || throw new ValidationException("source {$name} type prop must be string"); - in_array($src['type'], ['filelist', 'git', 'ghtagtar', 'ghtar', 'ghrel', 'url', 'custom']) || throw new ValidationException("source {$name} type [{$src['type']}] is invalid"); - switch ($src['type']) { - case 'filelist': - isset($src['url'], $src['regex']) || throw new ValidationException("source {$name} needs [url] and [regex] props"); - is_string($src['url']) && is_string($src['regex']) || throw new ValidationException("source {$name} [url] and [regex] must be string"); - break; - case 'git': - isset($src['url'], $src['rev']) || throw new ValidationException("source {$name} needs [url] and [rev] props"); - is_string($src['url']) && is_string($src['rev']) || throw new ValidationException("source {$name} [url] and [rev] must be string"); - is_string($src['path'] ?? '') || throw new ValidationException("source {$name} [path] must be string"); - break; - case 'ghtagtar': - case 'ghtar': - isset($src['repo']) || throw new ValidationException("source {$name} needs [repo] prop"); - is_string($src['repo']) || throw new ValidationException("source {$name} [repo] must be string"); - is_string($src['path'] ?? '') || throw new ValidationException("source {$name} [path] must be string"); - break; - case 'ghrel': - isset($src['repo'], $src['match']) || throw new ValidationException("source {$name} needs [repo] and [match] props"); - is_string($src['repo']) && is_string($src['match']) || throw new ValidationException("source {$name} [repo] and [match] must be string"); - break; - case 'url': - isset($src['url']) || throw new ValidationException("source {$name} needs [url] prop"); - is_string($src['url']) || throw new ValidationException("source {$name} [url] must be string"); - break; + // Validate basic source type configuration + self::validateSourceTypeConfig($src, $name, 'source'); + + // Check source-specific fields + // check if alt is valid + if (isset($src['alt'])) { + if (!is_assoc_array($src['alt']) && !is_bool($src['alt'])) { + throw new ValidationException("source {$name} alt must be object or boolean"); + } + if (is_assoc_array($src['alt'])) { + // validate alt source recursively + self::validateSource([$name . '_alt' => $src['alt']]); + } + } + + // check if provide-pre-built is boolean + if (isset($src['provide-pre-built']) && !is_bool($src['provide-pre-built'])) { + throw new ValidationException("source {$name} provide-pre-built must be boolean"); + } + + // check if prefer-stable is boolean + if (isset($src['prefer-stable']) && !is_bool($src['prefer-stable'])) { + throw new ValidationException("source {$name} prefer-stable must be boolean"); + } + + // check if license is valid + if (isset($src['license'])) { + if (!is_assoc_array($src['license'])) { + throw new ValidationException("source {$name} license must be object"); + } + if (!isset($src['license']['type'])) { + throw new ValidationException("source {$name} license must have type"); + } + if (!in_array($src['license']['type'], ['file', 'text'])) { + throw new ValidationException("source {$name} license type is invalid"); + } + if ($src['license']['type'] === 'file' && !isset($src['license']['path'])) { + throw new ValidationException("source {$name} license file must have path"); + } + if ($src['license']['type'] === 'text' && !isset($src['license']['text'])) { + throw new ValidationException("source {$name} license text must have text"); + } } } } @@ -78,7 +93,11 @@ class ConfigValidator if (isset($lib['source']) && !empty($source_data) && !isset($source_data[$lib['source']])) { throw new ValidationException("lib {$name} assigns an invalid source: {$lib['source']}"); } - // check if [lib-depends|lib-suggests|static-libs][-windows|-unix|-macos|-linux] are valid list array + // check if source is string + if (isset($lib['source']) && !is_string($lib['source'])) { + throw new ValidationException("lib {$name} source must be string"); + } + // check if [lib-depends|lib-suggests|static-libs|headers|bin][-windows|-unix|-macos|-linux] are valid list array $suffixes = ['', '-windows', '-unix', '-macos', '-linux']; foreach ($suffixes as $suffix) { if (isset($lib['lib-depends' . $suffix]) && !is_list_array($lib['lib-depends' . $suffix])) { @@ -93,6 +112,12 @@ class ConfigValidator if (isset($lib['pkg-configs' . $suffix]) && !is_list_array($lib['pkg-configs' . $suffix])) { throw new ValidationException("lib {$name} pkg-configs must be a list"); } + if (isset($lib['headers' . $suffix]) && !is_list_array($lib['headers' . $suffix])) { + throw new ValidationException("lib {$name} headers must be a list"); + } + if (isset($lib['bin' . $suffix]) && !is_list_array($lib['bin' . $suffix])) { + throw new ValidationException("lib {$name} bin must be a list"); + } } // check if frameworks is a list array if (isset($lib['frameworks']) && !is_list_array($lib['frameworks'])) { @@ -106,7 +131,65 @@ class ConfigValidator */ public static function validateExts(mixed $data): void { - is_array($data) || throw new ValidationException('ext.json is broken'); + if (!is_array($data)) { + throw new ValidationException('ext.json is broken'); + } + // check each extension + foreach ($data as $name => $ext) { + // check if ext is an assoc array + if (!is_assoc_array($ext)) { + throw new ValidationException("ext {$name} is not an object"); + } + // check if ext has valid type + if (!in_array($ext['type'] ?? '', ['builtin', 'external', 'addon', 'wip'])) { + throw new ValidationException("ext {$name} type is invalid"); + } + // check if external ext has source + if (($ext['type'] ?? '') === 'external' && !isset($ext['source'])) { + throw new ValidationException("ext {$name} does not assign any source"); + } + // check if source is string + if (isset($ext['source']) && !is_string($ext['source'])) { + throw new ValidationException("ext {$name} source must be string"); + } + // check if support is valid + if (isset($ext['support']) && !is_assoc_array($ext['support'])) { + throw new ValidationException("ext {$name} support must be an object"); + } + // check if notes is boolean + if (isset($ext['notes']) && !is_bool($ext['notes'])) { + throw new ValidationException("ext {$name} notes must be boolean"); + } + // check if [lib-depends|lib-suggests|ext-depends][-windows|-unix|-macos|-linux] are valid list array + $suffixes = ['', '-windows', '-unix', '-macos', '-linux']; + foreach ($suffixes as $suffix) { + if (isset($ext['lib-depends' . $suffix]) && !is_list_array($ext['lib-depends' . $suffix])) { + throw new ValidationException("ext {$name} lib-depends must be a list"); + } + if (isset($ext['lib-suggests' . $suffix]) && !is_list_array($ext['lib-suggests' . $suffix])) { + throw new ValidationException("ext {$name} lib-suggests must be a list"); + } + if (isset($ext['ext-depends' . $suffix]) && !is_list_array($ext['ext-depends' . $suffix])) { + throw new ValidationException("ext {$name} ext-depends must be a list"); + } + } + // check if arg-type is valid + if (isset($ext['arg-type'])) { + $valid_arg_types = ['enable', 'with', 'with-path', 'custom', 'none', 'enable-path']; + if (!in_array($ext['arg-type'], $valid_arg_types)) { + throw new ValidationException("ext {$name} arg-type is invalid"); + } + } + // check if arg-type with suffix is valid + foreach ($suffixes as $suffix) { + if (isset($ext['arg-type' . $suffix])) { + $valid_arg_types = ['enable', 'with', 'with-path', 'custom', 'none', 'enable-path']; + if (!in_array($ext['arg-type' . $suffix], $valid_arg_types)) { + throw new ValidationException("ext {$name} arg-type{$suffix} is invalid"); + } + } + } + } } /** @@ -114,7 +197,96 @@ class ConfigValidator */ public static function validatePkgs(mixed $data): void { - is_array($data) || throw new ValidationException('pkg.json is broken'); + if (!is_array($data)) { + throw new ValidationException('pkg.json is broken'); + } + // check each package + foreach ($data as $name => $pkg) { + // check if pkg is an assoc array + if (!is_assoc_array($pkg)) { + throw new ValidationException("pkg {$name} is not an object"); + } + + // Validate basic source type configuration (reuse from source validation) + self::validateSourceTypeConfig($pkg, $name, 'pkg'); + + // Check pkg-specific fields + // check if extract-files is valid + if (isset($pkg['extract-files'])) { + if (!is_assoc_array($pkg['extract-files'])) { + throw new ValidationException("pkg {$name} extract-files must be an object"); + } + // check each extract file mapping + foreach ($pkg['extract-files'] as $source => $target) { + if (!is_string($source) || !is_string($target)) { + throw new ValidationException("pkg {$name} extract-files mapping must be string to string"); + } + } + } + } + } + + /** + * Validate pre-built.json configuration + * + * @param mixed $data pre-built.json loaded data + * @throws ValidationException + */ + public static function validatePreBuilt(mixed $data): void + { + if (!is_array($data)) { + throw new ValidationException('pre-built.json is broken'); + } + + // Check required fields + if (!isset($data['repo'])) { + throw new ValidationException('pre-built.json must have [repo] field'); + } + if (!is_string($data['repo'])) { + throw new ValidationException('pre-built.json [repo] must be string'); + } + + // Check optional prefer-stable field + if (isset($data['prefer-stable']) && !is_bool($data['prefer-stable'])) { + throw new ValidationException('pre-built.json [prefer-stable] must be boolean'); + } + + // Check match pattern fields (at least one must exist) + $pattern_fields = ['match-pattern-linux', 'match-pattern-macos', 'match-pattern-windows']; + $has_pattern = false; + + foreach ($pattern_fields as $field) { + if (isset($data[$field])) { + $has_pattern = true; + if (!is_string($data[$field])) { + throw new ValidationException("pre-built.json [{$field}] must be string"); + } + // Validate pattern contains required placeholders + if (!str_contains($data[$field], '{name}')) { + throw new ValidationException("pre-built.json [{$field}] must contain {name} placeholder"); + } + if (!str_contains($data[$field], '{arch}')) { + throw new ValidationException("pre-built.json [{$field}] must contain {arch} placeholder"); + } + if (!str_contains($data[$field], '{os}')) { + throw new ValidationException("pre-built.json [{$field}] must contain {os} placeholder"); + } + + // Linux pattern should have libc-related placeholders + if ($field === 'match-pattern-linux') { + if (!str_contains($data[$field], '{libc}')) { + throw new ValidationException('pre-built.json [match-pattern-linux] must contain {libc} placeholder'); + } + if (!str_contains($data[$field], '{libcver}')) { + throw new ValidationException('pre-built.json [match-pattern-linux] must contain {libcver} placeholder'); + } + } + } + } + + if (!$has_pattern) { + throw new ValidationException('pre-built.json must have at least one match-pattern field'); + } } /** @@ -242,4 +414,85 @@ class ConfigValidator $craft['craft-options']['build'] ??= true; return $craft; } + + /** + * Validate source type configuration (shared between source.json and pkg.json) + * + * @param array $item The source/package item to validate + * @param string $name The name of the item for error messages + * @param string $config_type The type of config file ("source" or "pkg") + * @throws ValidationException + */ + private static function validateSourceTypeConfig(array $item, string $name, string $config_type): void + { + if (!isset($item['type'])) { + throw new ValidationException("{$config_type} {$name} must have prop: [type]"); + } + if (!is_string($item['type'])) { + throw new ValidationException("{$config_type} {$name} type prop must be string"); + } + if (!in_array($item['type'], ['filelist', 'git', 'ghtagtar', 'ghtar', 'ghrel', 'url', 'custom'])) { + throw new ValidationException("{$config_type} {$name} type [{$item['type']}] is invalid"); + } + + // Validate type-specific requirements + switch ($item['type']) { + case 'filelist': + if (!isset($item['url'], $item['regex'])) { + throw new ValidationException("{$config_type} {$name} needs [url] and [regex] props"); + } + if (!is_string($item['url']) || !is_string($item['regex'])) { + throw new ValidationException("{$config_type} {$name} [url] and [regex] must be string"); + } + break; + case 'git': + if (!isset($item['url'], $item['rev'])) { + throw new ValidationException("{$config_type} {$name} needs [url] and [rev] props"); + } + if (!is_string($item['url']) || !is_string($item['rev'])) { + throw new ValidationException("{$config_type} {$name} [url] and [rev] must be string"); + } + if (isset($item['path']) && !is_string($item['path'])) { + throw new ValidationException("{$config_type} {$name} [path] must be string"); + } + break; + case 'ghtagtar': + case 'ghtar': + if (!isset($item['repo'])) { + throw new ValidationException("{$config_type} {$name} needs [repo] prop"); + } + if (!is_string($item['repo'])) { + throw new ValidationException("{$config_type} {$name} [repo] must be string"); + } + if (isset($item['path']) && !is_string($item['path'])) { + throw new ValidationException("{$config_type} {$name} [path] must be string"); + } + break; + case 'ghrel': + if (!isset($item['repo'], $item['match'])) { + throw new ValidationException("{$config_type} {$name} needs [repo] and [match] props"); + } + if (!is_string($item['repo']) || !is_string($item['match'])) { + throw new ValidationException("{$config_type} {$name} [repo] and [match] must be string"); + } + break; + case 'url': + if (!isset($item['url'])) { + throw new ValidationException("{$config_type} {$name} needs [url] prop"); + } + if (!is_string($item['url'])) { + throw new ValidationException("{$config_type} {$name} [url] must be string"); + } + if (isset($item['filename']) && !is_string($item['filename'])) { + throw new ValidationException("{$config_type} {$name} [filename] must be string"); + } + if (isset($item['path']) && !is_string($item['path'])) { + throw new ValidationException("{$config_type} {$name} [path] must be string"); + } + break; + case 'custom': + // custom type has no specific requirements + break; + } + } } diff --git a/src/SPC/util/CustomExt.php b/src/SPC/util/CustomExt.php index bbd4adf7..8bdf287d 100644 --- a/src/SPC/util/CustomExt.php +++ b/src/SPC/util/CustomExt.php @@ -8,16 +8,30 @@ use SPC\builder\Extension; use SPC\exception\FileSystemException; use SPC\store\FileSystem; +/** + * Custom extension attribute and manager + * + * This class provides functionality to register and manage custom PHP extensions + * that can be used during the build process. + */ #[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS)] class CustomExt { private static array $custom_ext_class = []; + /** + * Constructor for custom extension attribute + * + * @param string $ext_name The extension name + */ public function __construct(protected string $ext_name) {} /** * Load all custom extension classes * + * This method scans the extension directory and registers all classes + * that have the CustomExt attribute. + * * @throws \ReflectionException * @throws FileSystemException */ @@ -32,6 +46,12 @@ class CustomExt } } + /** + * Get the class name for a custom extension + * + * @param string $ext_name The extension name + * @return string The class name for the extension + */ public static function getExtClass(string $ext_name): string { return self::$custom_ext_class[$ext_name] ?? Extension::class; diff --git a/src/SPC/util/DependencyUtil.php b/src/SPC/util/DependencyUtil.php index 8985e4bc..6a1c572d 100644 --- a/src/SPC/util/DependencyUtil.php +++ b/src/SPC/util/DependencyUtil.php @@ -9,13 +9,25 @@ use SPC\exception\WrongUsageException; use SPC\store\Config; /** - * Dependency processing tool class, including processing extensions, library dependency list order, etc. + * Dependency processing tool class + * + * This class handles processing extensions, library dependency list ordering, etc. + * It provides utilities for managing dependencies between extensions and libraries. */ class DependencyUtil { /** - * Convert platform extensions to library dependencies and suggestions. + * Convert platform extensions to library dependencies and suggestions * + * This method processes all extensions and libraries to create a comprehensive + * dependency map that can be used for build ordering. + * + * Returns an associative array where the key is the extension or library name (string), + * and the value is an array with two keys: + * - 'depends': array of dependency names (string) + * - 'suggests': array of suggested dependency names (string) + * + * @return array, suggests: array}> * @throws WrongUsageException * @throws FileSystemException */ @@ -53,6 +65,10 @@ class DependencyUtil } /** + * Get library dependencies in correct order + * + * @param array $libs Array of library names + * @return array Ordered array of library names * @throws WrongUsageException * @throws FileSystemException */ @@ -88,7 +104,13 @@ class DependencyUtil } /** - * @throws FileSystemException|WrongUsageException + * Get extension dependencies in correct order + * + * @param array $exts Array of extension names + * @param array $additional_libs Array of additional libraries + * @return array Ordered array of extension names + * @throws WrongUsageException + * @throws FileSystemException */ public static function getExtsAndLibs(array $exts, array $additional_libs = [], bool $include_suggested_exts = false, bool $include_suggested_libs = false): array { @@ -155,9 +177,6 @@ class DependencyUtil return [$exts_final, $libs_final, $not_included_final]; } - /** - * @throws WrongUsageException - */ private static function doVisitPlat(array $deps, array $dep_list): array { // default: get extension exts and libs sorted by dep_list diff --git a/src/SPC/util/PkgConfigUtil.php b/src/SPC/util/PkgConfigUtil.php index 87075b49..fd11df61 100644 --- a/src/SPC/util/PkgConfigUtil.php +++ b/src/SPC/util/PkgConfigUtil.php @@ -6,15 +6,23 @@ namespace SPC\util; use SPC\exception\RuntimeException; +/** + * Utility class for pkg-config operations + * + * This class provides methods to interact with pkg-config to get + * compilation flags and library information for building extensions. + */ class PkgConfigUtil { /** - * Returns --cflags-only-other output. + * Get CFLAGS from pkg-config + * + * Returns --cflags-only-other output from pkg-config. * The reason we return the string is we cannot use array_unique() on cflags, * some cflags may contains spaces. * - * @param string $pkg_config_str .pc file str, accepts multiple files - * @return string cflags string, e.g. "-Wno-implicit-int-float-conversion ..." + * @param string $pkg_config_str .pc file string, accepts multiple files + * @return string CFLAGS string, e.g. "-Wno-implicit-int-float-conversion ..." * @throws RuntimeException */ public static function getCflags(string $pkg_config_str): string @@ -25,10 +33,12 @@ class PkgConfigUtil } /** + * Get library flags from pkg-config + * * Returns --libs-only-l and --libs-only-other output. * The reason we return the array is to avoid duplicate lib defines. * - * @param string $pkg_config_str .pc file str, accepts multiple files + * @param string $pkg_config_str .pc file string, accepts multiple files * @return array Unique libs array, e.g. [-lz, -lxml, ...] * @throws RuntimeException */ @@ -64,6 +74,13 @@ class PkgConfigUtil return array_reverse(array_unique(array_reverse($libs))); } + /** + * Execute pkg-config command and return result + * + * @param string $cmd The pkg-config command to execute + * @return string The command output + * @throws RuntimeException If command fails + */ private static function execWithResult(string $cmd): string { f_exec($cmd, $output, $result_code); diff --git a/tests/SPC/builder/BuilderTest.php b/tests/SPC/builder/BuilderTest.php index b80f4a0d..ac55375c 100644 --- a/tests/SPC/builder/BuilderTest.php +++ b/tests/SPC/builder/BuilderTest.php @@ -103,7 +103,7 @@ class BuilderTest extends TestCase { if (file_exists(SOURCE_PATH . '/php-src/main/php_version.h')) { $file = SOURCE_PATH . '/php-src/main/php_version.h'; - $cnt = preg_match('/PHP_VERSION "(\d+\.\d+\.\d+)"/', file_get_contents($file), $match); + $cnt = preg_match('/PHP_VERSION "(\d+\.\d+\.\d+(?:-[^"]+)?)/', file_get_contents($file), $match); if ($cnt !== 0) { $this->assertEquals($match[1], $this->builder->getPHPVersion()); } else { diff --git a/tests/SPC/util/ConfigValidatorTest.php b/tests/SPC/util/ConfigValidatorTest.php index 6805509c..ad31791d 100644 --- a/tests/SPC/util/ConfigValidatorTest.php +++ b/tests/SPC/util/ConfigValidatorTest.php @@ -44,6 +44,35 @@ class ConfigValidatorTest extends TestCase 'type' => 'url', 'url' => 'https://example.com', ], + 'source7' => [ + 'type' => 'url', + 'url' => 'https://example.com', + 'filename' => 'test.tar.gz', + 'path' => 'test/path', + 'provide-pre-built' => true, + 'prefer-stable' => false, + 'license' => [ + 'type' => 'file', + 'path' => 'LICENSE', + ], + ], + 'source8' => [ + 'type' => 'url', + 'url' => 'https://example.com', + 'alt' => [ + 'type' => 'url', + 'url' => 'https://alt.example.com', + ], + ], + 'source9' => [ + 'type' => 'url', + 'url' => 'https://example.com', + 'alt' => false, + 'license' => [ + 'type' => 'text', + 'text' => 'MIT License', + ], + ], ]; try { ConfigValidator::validateSource($good_source); @@ -83,6 +112,47 @@ class ConfigValidatorTest extends TestCase 'source6' => [ 'type' => 'url', // no url ], + 'source7' => [ + 'type' => 'url', + 'url' => 'https://example.com', + 'provide-pre-built' => 'not boolean', // not boolean + ], + 'source8' => [ + 'type' => 'url', + 'url' => 'https://example.com', + 'prefer-stable' => 'not boolean', // not boolean + ], + 'source9' => [ + 'type' => 'url', + 'url' => 'https://example.com', + 'license' => 'not object', // not object + ], + 'source10' => [ + 'type' => 'url', + 'url' => 'https://example.com', + 'license' => [ + 'type' => 'invalid', // invalid type + ], + ], + 'source11' => [ + 'type' => 'url', + 'url' => 'https://example.com', + 'license' => [ + 'type' => 'file', // missing path + ], + ], + 'source12' => [ + 'type' => 'url', + 'url' => 'https://example.com', + 'license' => [ + 'type' => 'text', // missing text + ], + ], + 'source13' => [ + 'type' => 'url', + 'url' => 'https://example.com', + 'alt' => 'not object or boolean', // not object or boolean + ], ]; foreach ($bad_source as $name => $src) { try { @@ -112,9 +182,38 @@ class ConfigValidatorTest extends TestCase 'lib1', ], ], + 'lib4' => [ + 'source' => 'source4', + 'headers' => [ + 'header1.h', + 'header2.h', + ], + 'headers-windows' => [ + 'windows_header.h', + ], + 'bin-unix' => [ + 'binary1', + 'binary2', + ], + 'frameworks' => [ + 'CoreFoundation', + 'SystemConfiguration', + ], + ], + 'lib5' => [ + 'type' => 'package', + 'source' => 'source5', + 'pkg-configs' => [ + 'pkg1', + 'pkg2', + ], + ], + 'lib6' => [ + 'type' => 'root', + ], ]; try { - ConfigValidator::validateLibs($good_libs, ['source1' => [], 'source2' => [], 'source3' => []]); + ConfigValidator::validateLibs($good_libs, ['source1' => [], 'source2' => [], 'source3' => [], 'source4' => [], 'source5' => []]); $this->assertTrue(true); } catch (ValidationException $e) { $this->fail($e->getMessage()); @@ -193,6 +292,20 @@ class ConfigValidatorTest extends TestCase } catch (ValidationException) { $this->assertTrue(true); } + // headers must be list + try { + ConfigValidator::validateLibs(['lib1' => ['source' => 'source1', 'headers' => 'not list']], ['source1' => [], 'source2' => []]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + // bin must be list + try { + ConfigValidator::validateLibs(['lib1' => ['source' => 'source1', 'bin-unix' => 'not list']], ['source1' => [], 'source2' => []]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } } /** @@ -200,15 +313,459 @@ class ConfigValidatorTest extends TestCase */ public function testValidateExts(): void { - ConfigValidator::validateExts([]); + // Test valid extensions + $valid_exts = [ + 'ext1' => [ + 'type' => 'builtin', + ], + 'ext2' => [ + 'type' => 'external', + 'source' => 'source1', + ], + 'ext3' => [ + 'type' => 'external', + 'source' => 'source2', + 'arg-type' => 'enable', + 'lib-depends' => ['lib1'], + 'lib-suggests' => ['lib2'], + 'ext-depends-windows' => ['ext1'], + 'support' => [ + 'Windows' => 'wip', + 'BSD' => 'wip', + ], + 'notes' => true, + ], + 'ext4' => [ + 'type' => 'external', + 'source' => 'source3', + 'arg-type-unix' => 'with-path', + 'arg-type-windows' => 'with', + ], + ]; + ConfigValidator::validateExts($valid_exts); + + // Test invalid data $this->expectException(ValidationException::class); ConfigValidator::validateExts(null); } + public function testValidateExtsBad(): void + { + // Test invalid extension type + try { + ConfigValidator::validateExts(['ext1' => ['type' => 'invalid']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test external extension without source + try { + ConfigValidator::validateExts(['ext1' => ['type' => 'external']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test non-object extension + try { + ConfigValidator::validateExts(['ext1' => 'not object']); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test invalid source type + try { + ConfigValidator::validateExts(['ext1' => ['type' => 'external', 'source' => true]]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test invalid support + try { + ConfigValidator::validateExts(['ext1' => ['type' => 'builtin', 'support' => 'not object']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test invalid notes + try { + ConfigValidator::validateExts(['ext1' => ['type' => 'builtin', 'notes' => 'not boolean']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test invalid lib-depends + try { + ConfigValidator::validateExts(['ext1' => ['type' => 'builtin', 'lib-depends' => 'not list']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test invalid arg-type + try { + ConfigValidator::validateExts(['ext1' => ['type' => 'builtin', 'arg-type' => 'invalid']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test invalid arg-type with suffix + try { + ConfigValidator::validateExts(['ext1' => ['type' => 'builtin', 'arg-type-unix' => 'invalid']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + } + public function testValidatePkgs(): void { - ConfigValidator::validatePkgs([]); + // Test valid packages (all supported types) + $valid_pkgs = [ + 'pkg1' => [ + 'type' => 'url', + 'url' => 'https://example.com/file.tar.gz', + ], + 'pkg2' => [ + 'type' => 'ghrel', + 'repo' => 'owner/repo', + 'match' => 'file.+\.tar\.gz', + ], + 'pkg3' => [ + 'type' => 'custom', + ], + 'pkg4' => [ + 'type' => 'url', + 'url' => 'https://example.com/archive.zip', + 'filename' => 'archive.zip', + 'path' => 'extract/path', + 'extract-files' => [ + 'source/file.exe' => '{pkg_root_path}/bin/file.exe', + 'source/lib.dll' => '{pkg_root_path}/lib/lib.dll', + ], + ], + 'pkg5' => [ + 'type' => 'ghrel', + 'repo' => 'owner/repo', + 'match' => 'release.+\.zip', + 'extract-files' => [ + 'binary' => '{pkg_root_path}/bin/binary', + ], + ], + 'pkg6' => [ + 'type' => 'filelist', + 'url' => 'https://example.com/filelist', + 'regex' => '/href="(?.*\.tar\.gz)"/', + ], + 'pkg7' => [ + 'type' => 'git', + 'url' => 'https://github.com/owner/repo.git', + 'rev' => 'main', + ], + 'pkg8' => [ + 'type' => 'git', + 'url' => 'https://github.com/owner/repo.git', + 'rev' => 'v1.0.0', + 'path' => 'subdir/path', + ], + 'pkg9' => [ + 'type' => 'ghtagtar', + 'repo' => 'owner/repo', + ], + 'pkg10' => [ + 'type' => 'ghtar', + 'repo' => 'owner/repo', + 'path' => 'subdir', + ], + ]; + ConfigValidator::validatePkgs($valid_pkgs); + + // Test invalid data $this->expectException(ValidationException::class); ConfigValidator::validatePkgs(null); } + + public function testValidatePkgsBad(): void + { + // Test invalid package type + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'invalid']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test non-object package + try { + ConfigValidator::validatePkgs(['pkg1' => 'not object']); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test filelist type without url + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'filelist', 'regex' => '.*']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test filelist type without regex + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'filelist', 'url' => 'https://example.com']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test git type without url + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'git', 'rev' => 'main']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test git type without rev + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'git', 'url' => 'https://github.com/owner/repo.git']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test ghtagtar type without repo + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'ghtagtar']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test ghtar type without repo + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'ghtar']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test url type without url + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'url']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test url type with non-string url + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'url', 'url' => true]]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test ghrel type without repo + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'ghrel', 'match' => 'pattern']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test ghrel type without match + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'ghrel', 'repo' => 'owner/repo']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test ghrel type with non-string repo + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'ghrel', 'repo' => true, 'match' => 'pattern']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test ghrel type with non-string match + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'ghrel', 'repo' => 'owner/repo', 'match' => 123]]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test git type with non-string path + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'git', 'url' => 'https://github.com/owner/repo.git', 'rev' => 'main', 'path' => 123]]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test url type with non-string filename + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'url', 'url' => 'https://example.com', 'filename' => 123]]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test invalid extract-files (not object) + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'url', 'url' => 'https://example.com', 'extract-files' => 'not object']]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test invalid extract-files mapping (non-string key) + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'url', 'url' => 'https://example.com', 'extract-files' => [123 => 'target']]]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test invalid extract-files mapping (non-string value) + try { + ConfigValidator::validatePkgs(['pkg1' => ['type' => 'url', 'url' => 'https://example.com', 'extract-files' => ['source' => 123]]]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + } + + public function testValidatePreBuilt(): void + { + // Test valid pre-built configurations + $valid_prebuilt = [ + 'basic' => [ + 'repo' => 'static-php/static-php-cli-hosted', + 'match-pattern-linux' => '{name}-{arch}-{os}-{libc}-{libcver}.txz', + ], + 'full' => [ + 'repo' => 'static-php/static-php-cli-hosted', + 'prefer-stable' => true, + 'match-pattern-linux' => '{name}-{arch}-{os}-{libc}-{libcver}.txz', + 'match-pattern-macos' => '{name}-{arch}-{os}.txz', + 'match-pattern-windows' => '{name}-{arch}-{os}.tgz', + ], + 'prefer-stable-false' => [ + 'repo' => 'owner/repo', + 'prefer-stable' => false, + 'match-pattern-macos' => '{name}-{arch}-{os}.tar.gz', + ], + ]; + + foreach ($valid_prebuilt as $name => $config) { + try { + ConfigValidator::validatePreBuilt($config); + $this->assertTrue(true, "Config {$name} should be valid"); + } catch (ValidationException $e) { + $this->fail("Config {$name} should be valid but got: " . $e->getMessage()); + } + } + } + + public function testValidatePreBuiltBad(): void + { + // Test non-array data + try { + ConfigValidator::validatePreBuilt('invalid'); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test missing repo + try { + ConfigValidator::validatePreBuilt(['match-pattern-linux' => '{name}-{arch}-{os}-{libc}-{libcver}.txz']); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test invalid repo type + try { + ConfigValidator::validatePreBuilt(['repo' => 123, 'match-pattern-linux' => '{name}-{arch}-{os}-{libc}-{libcver}.txz']); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test invalid prefer-stable type + try { + ConfigValidator::validatePreBuilt(['repo' => 'owner/repo', 'prefer-stable' => 'true', 'match-pattern-linux' => '{name}-{arch}-{os}-{libc}-{libcver}.txz']); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test no match patterns + try { + ConfigValidator::validatePreBuilt(['repo' => 'owner/repo']); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test invalid match pattern type + try { + ConfigValidator::validatePreBuilt(['repo' => 'owner/repo', 'match-pattern-linux' => 123]); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test missing {name} placeholder + try { + ConfigValidator::validatePreBuilt(['repo' => 'owner/repo', 'match-pattern-linux' => '{arch}-{os}-{libc}-{libcver}.txz']); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test missing {arch} placeholder + try { + ConfigValidator::validatePreBuilt(['repo' => 'owner/repo', 'match-pattern-linux' => '{name}-{os}-{libc}-{libcver}.txz']); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test missing {os} placeholder + try { + ConfigValidator::validatePreBuilt(['repo' => 'owner/repo', 'match-pattern-linux' => '{name}-{arch}-{libc}-{libcver}.txz']); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test linux pattern missing {libc} placeholder + try { + ConfigValidator::validatePreBuilt(['repo' => 'owner/repo', 'match-pattern-linux' => '{name}-{arch}-{os}-{libcver}.txz']); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + + // Test linux pattern missing {libcver} placeholder + try { + ConfigValidator::validatePreBuilt(['repo' => 'owner/repo', 'match-pattern-linux' => '{name}-{arch}-{os}-{libc}.txz']); + $this->fail('should throw ValidationException'); + } catch (ValidationException) { + $this->assertTrue(true); + } + } } diff --git a/tests/SPC/util/DependencyUtilTest.php b/tests/SPC/util/DependencyUtilTest.php index ffc515be..468f6eb2 100644 --- a/tests/SPC/util/DependencyUtilTest.php +++ b/tests/SPC/util/DependencyUtilTest.php @@ -14,15 +14,29 @@ use SPC\util\DependencyUtil; */ final class DependencyUtilTest extends TestCase { - public function testGetExtLibsByDeps(): void + private array $originalConfig; + + protected function setUp(): void { - // setup - $bak = [ + // Save original configuration + $this->originalConfig = [ 'source' => Config::$source, 'lib' => Config::$lib, 'ext' => Config::$ext, ]; - // example + } + + protected function tearDown(): void + { + // Restore original configuration + Config::$source = $this->originalConfig['source']; + Config::$lib = $this->originalConfig['lib']; + Config::$ext = $this->originalConfig['ext']; + } + + public function testGetExtLibsByDeps(): void + { + // Set up test data Config::$source = [ 'test1' => [ 'type' => 'url', @@ -73,14 +87,15 @@ final class DependencyUtilTest extends TestCase 'lib-depends' => ['libeee'], ], ]; - // test getExtLibsByDeps (notmal test with ext-depends and lib-depends) + // Test dependency resolution [$exts, $libs, $not_included] = DependencyUtil::getExtsAndLibs(['ext-a'], include_suggested_exts: true); $this->assertContains('libbbb', $libs); $this->assertContains('libccc', $libs); $this->assertContains('ext-b', $exts); $this->assertContains('ext-b', $not_included); - // test dep order + + // Test dependency order $this->assertIsInt($b = array_search('libbbb', $libs)); $this->assertIsInt($c = array_search('libccc', $libs)); $this->assertIsInt($a = array_search('libaaa', $libs)); @@ -88,10 +103,6 @@ final class DependencyUtilTest extends TestCase $this->assertTrue($b < $a); $this->assertTrue($c < $a); $this->assertTrue($c < $b); - // restore - Config::$source = $bak['source']; - Config::$lib = $bak['lib']; - Config::$ext = $bak['ext']; } public function testNotExistExtException(): void diff --git a/tests/SPC/util/GlobalEnvManagerTest.php b/tests/SPC/util/GlobalEnvManagerTest.php new file mode 100644 index 00000000..28d60d34 --- /dev/null +++ b/tests/SPC/util/GlobalEnvManagerTest.php @@ -0,0 +1,135 @@ +originalEnv = [ + 'BUILD_ROOT_PATH' => getenv('BUILD_ROOT_PATH'), + 'SPC_TARGET' => getenv('SPC_TARGET'), + 'SPC_LIBC' => getenv('SPC_LIBC'), + ]; + } + + protected function tearDown(): void + { + // Restore original environment variables + foreach ($this->originalEnv as $key => $value) { + if ($value === false) { + putenv($key); + } else { + putenv("{$key}={$value}"); + } + } + } + + public function testGetInitializedEnv(): void + { + // Test that getInitializedEnv returns an array + $result = GlobalEnvManager::getInitializedEnv(); + $this->assertIsArray($result); + } + + /** + * @dataProvider envVariableProvider + */ + public function testPutenv(string $envVar): void + { + // Test putenv functionality + GlobalEnvManager::putenv($envVar); + + $env = GlobalEnvManager::getInitializedEnv(); + $this->assertContains($envVar, $env); + $this->assertEquals(explode('=', $envVar, 2)[1], getenv(explode('=', $envVar, 2)[0])); + } + + /** + * @dataProvider pathProvider + */ + public function testAddPathIfNotExistsOnUnix(string $path): void + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('This test is for Unix systems only'); + } + + $originalPath = getenv('PATH'); + GlobalEnvManager::addPathIfNotExists($path); + + $newPath = getenv('PATH'); + $this->assertStringContainsString($path, $newPath); + } + + /** + * @dataProvider pathProvider + */ + public function testAddPathIfNotExistsWhenPathAlreadyExists(string $path): void + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('This test is for Unix systems only'); + } + + GlobalEnvManager::addPathIfNotExists($path); + $pathAfterFirstAdd = getenv('PATH'); + + GlobalEnvManager::addPathIfNotExists($path); + $pathAfterSecondAdd = getenv('PATH'); + + // Should not add the same path twice + $this->assertEquals($pathAfterFirstAdd, $pathAfterSecondAdd); + } + + public function testInitWithoutBuildRootPath(): void + { + // Temporarily unset BUILD_ROOT_PATH + putenv('BUILD_ROOT_PATH'); + + $this->expectException(RuntimeException::class); + GlobalEnvManager::init(); + } + + public function testAfterInit(): void + { + // Set required environment variable + putenv('BUILD_ROOT_PATH=/test/path'); + putenv('SPC_SKIP_TOOLCHAIN_CHECK=true'); + + // Should not throw exception when SPC_SKIP_TOOLCHAIN_CHECK is true + GlobalEnvManager::afterInit(); + + $this->assertTrue(true); // Test passes if no exception is thrown + } + + public function envVariableProvider(): array + { + return [ + 'simple-env' => ['TEST_VAR=test_value'], + 'complex-env' => ['COMPLEX_VAR=complex_value_with_spaces'], + 'numeric-env' => ['NUMERIC_VAR=123'], + 'special-chars-env' => ['SPECIAL_VAR=test@#$%'], + ]; + } + + public function pathProvider(): array + { + return [ + 'simple-path' => ['/test/path'], + 'complex-path' => ['/usr/local/bin'], + 'home-path' => ['/home/user/bin'], + 'root-path' => ['/root/bin'], + ]; + } +} diff --git a/tests/SPC/util/PkgConfigUtilTest.php b/tests/SPC/util/PkgConfigUtilTest.php new file mode 100644 index 00000000..6d8988fd --- /dev/null +++ b/tests/SPC/util/PkgConfigUtilTest.php @@ -0,0 +1,206 @@ +assertEquals($expectedCflags, $result); + } + + /** + * @dataProvider validPackageProvider + */ + public function testGetLibsArrayWithValidPackage(string $package, string $expectedCflags, array $expectedLibs): void + { + $result = PkgConfigUtil::getLibsArray($package); + $this->assertEquals($expectedLibs, $result); + } + + /** + * @dataProvider invalidPackageProvider + */ + public function testGetCflagsWithInvalidPackage(string $package): void + { + $this->expectException(RuntimeException::class); + PkgConfigUtil::getCflags($package); + } + + /** + * @dataProvider invalidPackageProvider + */ + public function testGetLibsArrayWithInvalidPackage(string $package): void + { + $this->expectException(RuntimeException::class); + PkgConfigUtil::getLibsArray($package); + } + + public static function invalidPackageProvider(): array + { + return [ + 'invalid-package' => ['invalid-package'], + 'empty-string' => [''], + 'non-existent-package' => ['non-existent-package'], + ]; + } + + public static function validPackageProvider(): array + { + return [ + 'libxml2' => ['libxml-2.0', '-I/usr/include/libxml2', ['-lxml2', '']], + 'zlib' => ['zlib', '-I/usr/include', ['-lz', '']], + 'openssl' => ['openssl', '-I/usr/include/openssl', ['-lssl', '-lcrypto', '']], + ]; + } + + /** + * Create a fake pkg-config executable + */ + private static function createFakePkgConfig(): void + { + $pkgConfigScript = self::$fakePkgConfigPath . '/pkg-config'; + + $script = <<<'SCRIPT' +#!/bin/bash + +# Fake pkg-config script for testing +# Shift arguments to get the package name +shift + +case "$1" in + --cflags-only-other) + shift + case "$1" in + libxml-2.0) + echo "-I/usr/include/libxml2" + ;; + zlib) + echo "-I/usr/include" + ;; + openssl) + echo "-I/usr/include/openssl" + ;; + *) + echo "Package '$1' was not found in the pkg-config search path." >&2 + exit 1 + ;; + esac + ;; + --libs-only-l) + shift + case "$1" in + libxml-2.0) + echo "-lxml2" + ;; + zlib) + echo "-lz" + ;; + openssl) + echo "-lssl -lcrypto" + ;; + *) + echo "Package '$1' was not found in the pkg-config search path." >&2 + exit 1 + ;; + esac + ;; + --libs-only-other) + shift + case "$1" in + libxml-2.0) + echo "" + ;; + zlib) + echo "" + ;; + openssl) + echo "" + ;; + *) + echo "Package '$1' was not found in the pkg-config search path." >&2 + exit 1 + ;; + esac + ;; + *) + echo "Usage: pkg-config [OPTION] [PACKAGE]" >&2 + echo "Try 'pkg-config --help' for more information." >&2 + exit 1 + ;; +esac +SCRIPT; + + file_put_contents($pkgConfigScript, $script); + chmod($pkgConfigScript, 0755); + } + + /** + * Remove directory recursively + */ + private static function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + if (is_dir($path)) { + self::removeDirectory($path); + } else { + unlink($path); + } + } + rmdir($dir); + } +} diff --git a/tests/SPC/util/SPCTargetTest.php b/tests/SPC/util/SPCTargetTest.php new file mode 100644 index 00000000..dc003932 --- /dev/null +++ b/tests/SPC/util/SPCTargetTest.php @@ -0,0 +1,140 @@ +originalEnv = [ + 'SPC_TARGET' => getenv('SPC_TARGET'), + 'SPC_LIBC' => getenv('SPC_LIBC'), + ]; + } + + protected function tearDown(): void + { + // Restore original environment variables + foreach ($this->originalEnv as $key => $value) { + if ($value === false) { + putenv($key); + } else { + putenv("{$key}={$value}"); + } + } + } + + /** + * @dataProvider libcProvider + */ + public function testIsStatic(string $libc, bool $expected): void + { + putenv("SPC_LIBC={$libc}"); + + $result = SPCTarget::isStatic(); + $this->assertEquals($expected, $result); + } + + /** + * @dataProvider libcProvider + */ + public function testGetLibc(string $libc, bool $expected): void + { + putenv("SPC_LIBC={$libc}"); + + $result = SPCTarget::getLibc(); + if ($libc === '') { + // When SPC_LIBC is set to empty string, getenv returns empty string, not false + $this->assertEquals('', $result); + } else { + $this->assertEquals($libc, $result); + } + } + + /** + * @dataProvider libcProvider + */ + public function testGetLibcVersion(string $libc): void + { + putenv("SPC_LIBC={$libc}"); + + $result = SPCTarget::getLibcVersion(); + // The actual result depends on the system, but it could be null if libc is not available + $this->assertIsStringOrNull($result); + } + + /** + * @dataProvider targetOSProvider + */ + public function testGetTargetOS(string $target, string $expected): void + { + putenv("SPC_TARGET={$target}"); + + $result = SPCTarget::getTargetOS(); + $this->assertEquals($expected, $result); + } + + /** + * @dataProvider invalidTargetProvider + */ + public function testGetTargetOSWithInvalidTarget(string $target): void + { + putenv("SPC_TARGET={$target}"); + + $this->expectException(WrongUsageException::class); + $this->expectExceptionMessage('Cannot parse target.'); + + SPCTarget::getTargetOS(); + } + + public function testLibcListConstant(): void + { + $this->assertIsArray(SPCTarget::LIBC_LIST); + $this->assertContains('musl', SPCTarget::LIBC_LIST); + $this->assertContains('glibc', SPCTarget::LIBC_LIST); + } + + public function libcProvider(): array + { + return [ + 'musl' => ['musl', true], + 'glibc' => ['glibc', false], + 'empty' => ['', false], + ]; + } + + public function targetOSProvider(): array + { + return [ + 'linux-target' => ['linux-x86_64', 'Linux'], + 'macos-target' => ['macos-x86_64', 'Darwin'], + 'windows-target' => ['windows-x86_64', 'Windows'], + 'empty-target' => ['', PHP_OS_FAMILY], + ]; + } + + public function invalidTargetProvider(): array + { + return [ + 'invalid-target' => ['invalid-target'], + 'unknown-target' => ['unknown-target'], + 'mixed-target' => ['mixed-target'], + ]; + } + + private function assertIsStringOrNull($value): void + { + $this->assertTrue(is_string($value) || is_null($value), 'Value must be string or null'); + } +} diff --git a/tests/SPC/util/TestBase.php b/tests/SPC/util/TestBase.php new file mode 100644 index 00000000..c7e8b22d --- /dev/null +++ b/tests/SPC/util/TestBase.php @@ -0,0 +1,100 @@ +suppressOutput(); + } + + protected function tearDown(): void + { + $this->restoreOutput(); + parent::tearDown(); + } + + /** + * Suppress output during tests + */ + protected function suppressOutput(): void + { + // Start output buffering to capture PHP output + $this->outputBuffer = ob_start(); + } + + /** + * Restore output after tests + */ + protected function restoreOutput(): void + { + // Clean output buffer + if ($this->outputBuffer) { + ob_end_clean(); + } + } + + /** + * Create a UnixShell instance with debug disabled to suppress logs + */ + protected function createUnixShell(): \SPC\util\UnixShell + { + return new \SPC\util\UnixShell(false); + } + + /** + * Create a WindowsCmd instance with debug disabled to suppress logs + */ + protected function createWindowsCmd(): \SPC\util\WindowsCmd + { + return new \SPC\util\WindowsCmd(false); + } + + /** + * Run a test with output suppression + */ + protected function runWithOutputSuppression(callable $callback) + { + $this->suppressOutput(); + try { + return $callback(); + } finally { + $this->restoreOutput(); + } + } + + /** + * Execute a command with output suppression + */ + protected function execWithSuppression(string $command): array + { + $this->suppressOutput(); + try { + exec($command, $output, $returnCode); + return [$returnCode, $output]; + } finally { + $this->restoreOutput(); + } + } + + /** + * Execute a command with output redirected to /dev/null + */ + protected function execSilently(string $command): array + { + $command .= ' 2>/dev/null 1>/dev/null'; + exec($command, $output, $returnCode); + return [$returnCode, $output]; + } +} diff --git a/tests/SPC/util/UnixShellTest.php b/tests/SPC/util/UnixShellTest.php new file mode 100644 index 00000000..0f16ead4 --- /dev/null +++ b/tests/SPC/util/UnixShellTest.php @@ -0,0 +1,184 @@ +markTestSkipped('This test is for Windows systems only'); + } + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Windows cannot use UnixShell'); + + new UnixShell(); + } + + /** + * @dataProvider envProvider + */ + public function testSetEnv(array $env): void + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('This test is for Unix systems only'); + } + + $shell = $this->createUnixShell(); + $result = $shell->setEnv($env); + + $this->assertSame($shell, $result); + foreach ($env as $item) { + if (trim($item) !== '') { + $this->assertStringContainsString($item, $shell->getEnvString()); + } + } + } + + /** + * @dataProvider envProvider + */ + public function testAppendEnv(array $env): void + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('This test is for Unix systems only'); + } + + $shell = $this->createUnixShell(); + $shell->setEnv(['CFLAGS' => '-O2']); + + $shell->appendEnv($env); + + $this->assertStringContainsString('-O2', $shell->getEnvString()); + foreach ($env as $value) { + if (trim($value) !== '') { + $this->assertStringContainsString($value, $shell->getEnvString()); + } + } + } + + /** + * @dataProvider envProvider + */ + public function testGetEnvString(array $env): void + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('This test is for Unix systems only'); + } + + $shell = $this->createUnixShell(); + $shell->setEnv($env); + + $envString = $shell->getEnvString(); + + $hasNonEmptyValues = false; + foreach ($env as $key => $value) { + if (trim($value) !== '') { + $this->assertStringContainsString("{$key}=\"{$value}\"", $envString); + $hasNonEmptyValues = true; + } + } + + // If all values are empty, ensure we still have a test assertion + if (!$hasNonEmptyValues) { + $this->assertIsString($envString); + } + } + + public function testGetEnvStringWithEmptyEnv(): void + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('This test is for Unix systems only'); + } + + $shell = $this->createUnixShell(); + $envString = $shell->getEnvString(); + + $this->assertEquals('', trim($envString)); + } + + /** + * @dataProvider commandProvider + */ + public function testExecWithResult(string $command): void + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('This test is for Unix systems only'); + } + + $shell = $this->createUnixShell(); + [$code, $output] = $shell->execWithResult($command); + + $this->assertIsInt($code); + $this->assertIsArray($output); + } + + public function testExecWithResultWithLog(): void + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('This test is for Unix systems only'); + } + + $shell = $this->createUnixShell(); + [$code, $output] = $shell->execWithResult('echo "test"', false); + + $this->assertIsInt($code); + $this->assertIsArray($output); + $this->assertEquals(0, $code); + $this->assertEquals(['test'], $output); + } + + public function testExecWithResultWithCd(): void + { + if (PHP_OS_FAMILY === 'Windows') { + $this->markTestSkipped('This test is for Unix systems only'); + } + + $shell = $this->createUnixShell(); + $shell->cd('/tmp'); + + [$code, $output] = $shell->execWithResult('pwd'); + + $this->assertIsInt($code); + $this->assertEquals(0, $code); + $this->assertIsArray($output); + } + + public static function directoryProvider(): array + { + return [ + 'simple-directory' => ['/test/directory'], + 'home-directory' => ['/home/user'], + 'root-directory' => ['/root'], + 'tmp-directory' => ['/tmp'], + ]; + } + + public static function envProvider(): array + { + return [ + 'simple-env' => [['CFLAGS' => '-O2', 'LDFLAGS' => '-L/usr/lib']], + 'complex-env' => [['CXXFLAGS' => '-std=c++11', 'LIBS' => '-lz -lxml']], + 'empty-env' => [['CFLAGS' => '', 'LDFLAGS' => ' ']], + 'mixed-env' => [['CFLAGS' => '-O2', 'EMPTY_VAR' => '']], + ]; + } + + public static function commandProvider(): array + { + return [ + 'echo-command' => ['echo "test"'], + 'pwd-command' => ['pwd'], + 'ls-command' => ['ls -la'], + ]; + } +} diff --git a/tests/SPC/util/WindowsCmdTest.php b/tests/SPC/util/WindowsCmdTest.php new file mode 100644 index 00000000..7d64d7e3 --- /dev/null +++ b/tests/SPC/util/WindowsCmdTest.php @@ -0,0 +1,68 @@ +markTestSkipped('This test is for Unix systems only'); + } + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Only windows can use WindowsCmd'); + + new WindowsCmd(); + } + + /** + * @dataProvider commandProvider + */ + public function testExecWithResult(string $command): void + { + if (PHP_OS_FAMILY !== 'Windows') { + $this->markTestSkipped('This test is for Windows systems only'); + } + + $cmd = $this->createWindowsCmd(); + [$code, $output] = $cmd->execWithResult($command); + + $this->assertIsInt($code); + $this->assertEquals(0, $code); + $this->assertIsArray($output); + $this->assertNotEmpty($output); + } + + public function testExecWithResultWithLog(): void + { + if (PHP_OS_FAMILY !== 'Windows') { + $this->markTestSkipped('This test is for Windows systems only'); + } + + $cmd = $this->createWindowsCmd(); + [$code, $output] = $cmd->execWithResult('echo test', false); + + $this->assertIsInt($code); + $this->assertIsArray($output); + $this->assertEquals(0, $code); + $this->assertEquals(['test'], $output); + } + + public static function commandProvider(): array + { + return [ + 'echo-command' => ['echo test'], + 'dir-command' => ['dir'], + 'cd-command' => ['cd'], + ]; + } +}