This commit is contained in:
DubbleClick 2025-07-29 10:20:30 +07:00
commit e8bc892d8b
27 changed files with 2118 additions and 188 deletions

View File

@ -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. 请不要在代码中提交更多无用的代码片段,例如大量未使用的变量、方法、类以及多次重写的代码。

View File

@ -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 项目本身不需要这么多扩展,但在开发过程中,你会用到 ComposerPHPUnit 等工具,它们需要这些扩展。
static-php-cli 项目本身不需要这么多扩展,但在开发过程中,你会用到 ComposerPHPUnit 等工具,它们需要这些扩展。
> 对于 static-php-cli 自身构建的 micro 自执行二进制,仅需要 `pcntl,posix,mbstring,tokenizer,phar`
## 开始开发
继续向下查看项目结构的文档,你可以从中了解 `static-php-cli` 是如何运作的。
继续向下查看项目结构文档,你可以学习 `static-php-cli` 是如何工作的。

View File

@ -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 编译器。
之前我在网上看到有一个项目是 BPCBinary PHP Compiler可以把 PHP 编译为二进制,但是限制也是很多很多。
不可以。micro.sfx 本质上是将 php 和 php 代码合并为一个文件,没有编译或加密 PHP 代码的过程。
加密保护代码的方向和编译也不是一回事,编译过后也可以通过逆向工程等方式拿到代码,真正保护还是通过加壳、加密代码等手段进行。
首先php-src 是 PHP 代码的官方解释器,市场上没有与主流分支兼容的 PHP 编译器。
我在网上看到一个名为 BPCBinary 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,但不会明确支持早期版本。

View File

@ -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
## 构建方法

View File

@ -4,8 +4,8 @@ static-php-cli 是一个用于构建静态编译的 PHP 二进制的工具,目
在指南章节中,你将了解到如何使用 static-php-cli 构建独立的 php 程序。
- [Action 构建](./action-build)
- [本地构建](./manual-build)
- [Action 构建](./action-build)
- [扩展列表](./extensions)
## 编译环境

View File

@ -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=<XXX>`
要解决这个问题,可以在 GitHub 上 [创建](https://github.com/settings/tokens) 一个个人访问令牌,并将其设置为环境变量 `GITHUB_TOKEN=<XXX>`
如果确认地址确实无法正常访问,可以提交 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#多次构建) 章节。

View File

@ -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;
}

View File

@ -263,7 +263,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') ?: '';
$libDir = BUILD_LIB_PATH;
$modulesDir = BUILD_MODULES_PATH;
$libphpSo = "{$libDir}/libphp.so";

View File

@ -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

View File

@ -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<int, string> [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<int, string> [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<int, string> [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<int, string> 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<int, string> [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 [

View File

@ -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

View File

@ -4,15 +4,43 @@ 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;
abstract public function extract(string $name): void;
/**
* Get the environment variables this package needs to be usable.
* PATH needs to be appended, rather than replaced.
*
* @return array
*/
abstract public static function getEnvironment(): array;
abstract public static function isInstalled(): bool;
/**
* Extract the downloaded package
*
* @param string $name Package name
*/
abstract public function extract(string $name): void;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -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<string, array{depends: array<int, string>, suggests: array<int, string>}>
* @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

View File

@ -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);

View File

@ -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 {

View File

@ -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="(?<file>.*\.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);
}
}
}

View File

@ -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

View File

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace SPC\Tests\util;
use PHPUnit\Framework\TestCase;
use SPC\exception\RuntimeException;
use SPC\util\GlobalEnvManager;
/**
* @internal
*/
final class GlobalEnvManagerTest extends TestCase
{
private array $originalEnv;
protected function setUp(): void
{
// Save original environment variables
$this->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'],
];
}
}

View File

@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace SPC\Tests\util;
use PHPUnit\Framework\TestCase;
use SPC\exception\RuntimeException;
use SPC\util\PkgConfigUtil;
/**
* @internal
*/
final class PkgConfigUtilTest extends TestCase
{
private static string $originalPath;
private static string $fakePkgConfigPath;
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
// Save original PATH
self::$originalPath = getenv('PATH');
// Create fake pkg-config directory
self::$fakePkgConfigPath = sys_get_temp_dir() . '/fake-pkg-config-' . uniqid();
mkdir(self::$fakePkgConfigPath, 0755, true);
// Create fake pkg-config executable
self::createFakePkgConfig();
// Add fake pkg-config to PATH
putenv('PATH=' . self::$fakePkgConfigPath . ':' . self::$originalPath);
}
public static function tearDownAfterClass(): void
{
// Restore original PATH
putenv('PATH=' . self::$originalPath);
// Clean up fake pkg-config
if (is_dir(self::$fakePkgConfigPath)) {
self::removeDirectory(self::$fakePkgConfigPath);
}
parent::tearDownAfterClass();
}
/**
* @dataProvider validPackageProvider
*/
public function testGetCflagsWithValidPackage(string $package, string $expectedCflags): void
{
$result = PkgConfigUtil::getCflags($package);
$this->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);
}
}

View File

@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace SPC\Tests\util;
use SPC\exception\WrongUsageException;
use SPC\util\SPCTarget;
/**
* @internal
*/
final class SPCTargetTest extends TestBase
{
private array $originalEnv;
protected function setUp(): void
{
// Save original environment variables
$this->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');
}
}

100
tests/SPC/util/TestBase.php Normal file
View File

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace SPC\Tests\util;
use PHPUnit\Framework\TestCase;
/**
* Base test class for util tests with output suppression
*/
abstract class TestBase extends TestCase
{
protected $outputBuffer;
protected function setUp(): void
{
parent::setUp();
$this->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];
}
}

View File

@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace SPC\Tests\util;
use SPC\exception\RuntimeException;
use SPC\util\UnixShell;
/**
* @internal
*/
final class UnixShellTest extends TestBase
{
public function testConstructorOnWindows(): void
{
if (PHP_OS_FAMILY !== 'Windows') {
$this->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'],
];
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace SPC\Tests\util;
use SPC\exception\RuntimeException;
use SPC\util\WindowsCmd;
/**
* @internal
*/
final class WindowsCmdTest extends TestBase
{
public function testConstructorOnUnix(): void
{
if (PHP_OS_FAMILY === 'Windows') {
$this->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'],
];
}
}