Skip to content

Commit a81dd6d

Browse files
committed
feat(tool): implement tool package support with validation and configuration
1 parent b4ed673 commit a81dd6d

10 files changed

Lines changed: 327 additions & 5 deletions

File tree

spc.registry.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ package:
1010
- config/pkg/lib/
1111
- config/pkg/target/
1212
- config/pkg/ext/
13+
- config/pkg/tool/
1314
artifact:
1415
config:
1516
- config/artifact/
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace StaticPHP\Attribute\Package;
6+
7+
/**
8+
* Indicates that the annotated class defines a tool package.
9+
*/
10+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
11+
readonly class Tool
12+
{
13+
public function __construct(public string $name) {}
14+
}

src/StaticPHP/Config/ConfigType.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ enum ConfigType
1919
'php-extension',
2020
'target',
2121
'virtual-target',
22+
'tool',
2223
];
2324

2425
public static function validateLicenseField(mixed $value): bool

src/StaticPHP/Config/ConfigValidator.php

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ class ConfigValidator
4444
'path' => ConfigType::LIST_ARRAY, // @
4545
'env' => ConfigType::ASSOC_ARRAY, // @
4646
'append-env' => ConfigType::ASSOC_ARRAY, // @
47+
48+
// tool type fields (nested under 'tool' key)
49+
'tool' => ConfigType::ASSOC_ARRAY,
50+
'provides' => ConfigType::LIST_ARRAY,
51+
'binary-subdir' => ConfigType::STRING,
52+
'install-root' => ConfigType::STRING,
53+
'min-version' => ConfigType::STRING,
4754
];
4855

4956
public const array PACKAGE_FIELDS = [
@@ -67,6 +74,9 @@ class ConfigValidator
6774
'path' => false, // @
6875
'env' => false, // @
6976
'append-env' => false, // @
77+
78+
// tool fields (nested object)
79+
'tool' => false,
7080
];
7181

7282
public const array SUFFIX_ALLOWED_FIELDS = [
@@ -78,6 +88,7 @@ class ConfigValidator
7888
'path',
7989
'env',
8090
'append-env',
91+
'tools',
8192
];
8293

8394
public const array PHP_EXTENSION_FIELDS = [
@@ -92,6 +103,13 @@ class ConfigValidator
92103
'os' => false,
93104
];
94105

106+
public const array TOOL_FIELDS = [
107+
'provides' => true,
108+
'binary-subdir' => false,
109+
'install-root' => false,
110+
'min-version' => false,
111+
];
112+
95113
public const array ARTIFACT_TYPE_FIELDS = [ // [required_fields, optional_fields]
96114
'filelist' => [['url', 'regex'], ['extract']],
97115
'git' => [['url'], ['extract', 'submodules', 'rev', 'regex']],
@@ -220,8 +238,8 @@ public static function validateAndLintPackages(string $config_file_name, mixed &
220238
$fields = self::SUFFIX_ALLOWED_FIELDS;
221239
self::validateSuffixAllowedFields($name, $pkg, $fields, $suffixes);
222240

223-
// check if "library|target" package has artifact field for target and library types
224-
if (in_array($pkg['type'], ['target', 'library']) && !isset($pkg['artifact'])) {
241+
// check if "library|target|tool" package has artifact field
242+
if (in_array($pkg['type'], ['target', 'library', 'tool']) && !isset($pkg['artifact'])) {
225243
throw new ValidationException("Package [{$name}] in {$config_file_name} of type '{$pkg['type']}' must have an 'artifact' field");
226244
}
227245

@@ -235,6 +253,11 @@ public static function validateAndLintPackages(string $config_file_name, mixed &
235253
self::validatePhpExtensionFields($name, $pkg);
236254
}
237255

256+
// check if "tool" package has tool specific fields and validate inside
257+
if ($pkg['type'] === 'tool') {
258+
self::validateToolFields($name, $pkg);
259+
}
260+
238261
// check for unknown fields
239262
self::validateNoInvalidFields('package', $name, $pkg, array_keys(self::PACKAGE_FIELD_TYPES));
240263
}
@@ -397,6 +420,29 @@ private static function validatePhpExtensionFields(int|string $name, mixed $pkg)
397420
self::validateNoInvalidFields('php-extension', $name, $pkg['php-extension'], array_keys(self::PHP_EXTENSION_FIELDS));
398421
}
399422

423+
/**
424+
* Validate tool specific fields for tool package type.
425+
*/
426+
private static function validateToolFields(int|string $name, mixed $pkg): void
427+
{
428+
if (!isset($pkg['tool'])) {
429+
throw new ValidationException("Package {$name} of type 'tool' must have a 'tool' field");
430+
}
431+
if (!is_assoc_array($pkg['tool'])) {
432+
throw new ValidationException("Package {$name} [tool] must be an object");
433+
}
434+
foreach (self::TOOL_FIELDS as $field => $required) {
435+
if ($required && !isset($pkg['tool'][$field])) {
436+
throw new ValidationException("Package {$name} [tool] must have required field [{$field}]");
437+
}
438+
if (isset($pkg['tool'][$field])) {
439+
self::validatePackageFieldType($field, $pkg['tool'][$field], $name);
440+
}
441+
}
442+
// check for unknown fields in tool
443+
self::validateNoInvalidFields('tool', $name, $pkg['tool'], array_keys(self::TOOL_FIELDS));
444+
}
445+
400446
private static function validateNoInvalidFields(string $config_type, int|string $item_name, mixed $item_content, array $allowed_fields): void
401447
{
402448
foreach ($item_content as $k => $v) {

src/StaticPHP/Config/PackageConfig.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class PackageConfig
1616

1717
/**
1818
* Load package configurations from a specified directory.
19-
* It will look for files matching the pattern 'pkg.*.json' and 'pkg.json'.
19+
* Only processes .json, .yml, and .yaml files (skips .gitkeep etc.).
2020
*/
2121
public static function loadFromDir(string $dir, string $registry_name): array
2222
{
@@ -28,6 +28,10 @@ public static function loadFromDir(string $dir, string $registry_name): array
2828
$files = FileSystem::scanDirFiles($dir, false);
2929
if (is_array($files)) {
3030
foreach ($files as $file) {
31+
$ext = pathinfo($file, PATHINFO_EXTENSION);
32+
if (!in_array($ext, ['json', 'yml', 'yaml'], true)) {
33+
continue;
34+
}
3135
self::loadFromFile($file, $registry_name);
3236
$loaded[] = $file;
3337
}
@@ -46,7 +50,7 @@ public static function loadFromDir(string $dir, string $registry_name): array
4650
*/
4751
public static function loadFromFile(string $file, string $registry_name): string
4852
{
49-
$content = @file_get_contents($file);
53+
$content = file_get_contents($file);
5054
if ($content === false) {
5155
throw new WrongUsageException("Failed to read package config file: {$file}");
5256
}

src/StaticPHP/Package/Package.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,20 @@ public function isInstalled(): bool
120120
return false;
121121
}
122122

123+
/**
124+
* Get the target directory where this package's artifacts should be placed.
125+
*
126+
* Libraries install to BUILD_ROOT_PATH (static-libs, headers, pkg-configs).
127+
* Tools install to PKG_ROOT_PATH (executables).
128+
* Extensions install to php-src/ext/ (shared objects).
129+
*
130+
* Override in subclasses to change the default.
131+
*/
132+
public function getInstallTarget(): string
133+
{
134+
return BUILD_ROOT_PATH;
135+
}
136+
123137
/**
124138
* Add a stage to the package.
125139
*/

src/StaticPHP/Package/PackageInstaller.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use StaticPHP\Artifact\DownloaderOptions;
1212
use StaticPHP\Config\PackageConfig;
1313
use StaticPHP\DI\ApplicationContext;
14+
use StaticPHP\Exception\EnvironmentException;
1415
use StaticPHP\Exception\WrongUsageException;
1516
use StaticPHP\Registry\PackageLoader;
1617
use StaticPHP\Runtime\SystemTarget;
@@ -167,6 +168,9 @@ public function run(bool $disable_delay_msg = false): void
167168
// Early validation: check if packages can be built or installed before downloading
168169
$this->validatePackagesBeforeBuild();
169170

171+
// Check that all required tools are installed before proceeding
172+
$this->ensureRequiredTools();
173+
170174
// check download
171175
if ($this->download) {
172176
$downloaderOptions = DownloaderOptions::extractFromConsoleOptions($this->options, 'dl');
@@ -574,6 +578,66 @@ public function getPhpExtensionPackage(string $package_or_ext_name): ?PhpExtensi
574578
return null;
575579
}
576580

581+
/**
582+
* Collect all tool packages required by the currently resolved packages.
583+
*
584+
* Reads the 'tools' field from each resolved package's YAML config.
585+
* The field supports platform suffixes (tools@windows, tools@linux, etc.)
586+
* resolved automatically by PackageConfig::get().
587+
*
588+
* Tools are NOT part of the library dependency graph — they are
589+
* build-time prerequisites that must be installed before any library
590+
* build begins.
591+
*
592+
* @return string[] Unique tool package names required for this build
593+
*/
594+
public function collectRequiredTools(): array
595+
{
596+
$tools = [];
597+
foreach ($this->packages as $package) {
598+
$deps = PackageConfig::get($package->getName(), 'tools', []);
599+
foreach ((array) $deps as $tool_name) {
600+
$tools[$tool_name] = true;
601+
}
602+
}
603+
return array_keys($tools);
604+
}
605+
606+
/**
607+
* Check that all required tools are installed.
608+
*
609+
* Iterates through tools collected by collectRequiredTools(),
610+
* resolves each to a ToolPackage instance, and checks isInstalled().
611+
*
612+
* @return array{missing: array<string>, installed: array<string>}
613+
*/
614+
public function checkRequiredTools(): array
615+
{
616+
$missing = [];
617+
$installed = [];
618+
foreach ($this->collectRequiredTools() as $tool_name) {
619+
try {
620+
$tool = PackageLoader::getPackage($tool_name);
621+
} catch (WrongUsageException) {
622+
$missing[] = $tool_name;
623+
logger()->warning("Required tool '{$tool_name}' is not registered as a package.");
624+
continue;
625+
}
626+
627+
if (!$tool instanceof ToolPackage) {
628+
logger()->warning("Package '{$tool_name}' is declared as a tool dependency but is not a ToolPackage (type: {$tool->getType()}).");
629+
continue;
630+
}
631+
632+
if ($tool->isInstalled()) {
633+
$installed[] = $tool_name;
634+
} else {
635+
$missing[] = $tool_name;
636+
}
637+
}
638+
return ['missing' => $missing, 'installed' => $installed];
639+
}
640+
577641
/**
578642
* @param Package[] $packages
579643
*/
@@ -636,6 +700,27 @@ private function resolvePackages(): void
636700
}
637701
}
638702

703+
/**
704+
* Ensure all required tools are installed, throwing if any are missing.
705+
*
706+
* Called early in the build pipeline (before download/extract).
707+
* When tools are missing, lists them with install hints.
708+
*/
709+
private function ensureRequiredTools(): void
710+
{
711+
$status = $this->checkRequiredTools();
712+
if (empty($status['missing'])) {
713+
if (!empty($status['installed'])) {
714+
logger()->info('Required tools: ' . implode(', ', $status['installed']) . ' — all installed.');
715+
}
716+
return;
717+
}
718+
719+
$msg = 'Missing required build tools: ' . implode(', ', $status['missing']) . "\n";
720+
$msg .= "Run 'bin/spc doctor' to check your environment, or install the missing tools manually.";
721+
throw new EnvironmentException($msg);
722+
}
723+
639724
private function injectPackageEnvs(Package $package): void
640725
{
641726
$name = $package->getName();

0 commit comments

Comments
 (0)