├── .editorconfig ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .php-cs-fixer.php ├── LICENSE ├── README.md ├── composer.json ├── phikl ├── phpunit.xml.dist ├── src ├── Attribute │ └── PklProperty.php ├── Cache │ ├── Adapter │ │ ├── AbstractRemoteCacheAdapter.php │ │ ├── ApcuCacheAdapter.php │ │ ├── MemcachedCacheAdapter.php │ │ └── MemcachedServer.php │ ├── Entry.php │ └── PersistentCache.php ├── Exception │ ├── CorruptedCacheException.php │ ├── EmptyCacheException.php │ └── PklCliAlreadyDownloadedException.php ├── Internal │ ├── Command │ │ └── Runner.php │ └── PklDownloader.php ├── Pkl.php ├── PklModule.php └── PklModuleInterface.php └── tests ├── Cache ├── Adapter │ ├── ApcuCacheTest.php │ └── MemcachedCacheTest.php └── PersistentCacheTest.php ├── Fixtures ├── Address.php ├── ClassWithAttributes.php ├── User.php ├── UserWithArrayAddress.php ├── UserWithAttributes.php ├── amends.pkl ├── multiple.pkl ├── name_surname.pkl ├── simple.pkl └── user.pkl ├── PhiklCommandTest.php ├── PklModuleTest.php └── PklTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_style = space 6 | indent_size = 4 7 | charset = utf-8 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: PHPUnit 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | services: 16 | memcached: 17 | image: memcached:1.6.5 18 | ports: 19 | - 11211:11211 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | 24 | - name: Setup PHP with fail-fast 25 | uses: shivammathur/setup-php@v2 26 | with: 27 | php-version: '8.2' 28 | extensions: apcu, memcached, curl 29 | ini-values: | 30 | apc.enable_cli=1 31 | 32 | - name: Validate composer.json and composer.lock 33 | run: composer validate --strict 34 | 35 | - name: Cache Composer packages 36 | id: composer-cache 37 | uses: actions/cache@v3 38 | with: 39 | path: vendor 40 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 41 | restore-keys: | 42 | ${{ runner.os }}-php- 43 | 44 | - name: Install dependencies 45 | run: composer install --prefer-dist --no-progress 46 | 47 | - name: Install PKL 48 | run: ./phikl install 49 | 50 | - name: PHPCS Fixer 51 | run: composer run-script cs 52 | 53 | - name: PHPStan 54 | run: composer run-script stan 55 | 56 | - name: Run test suite 57 | run: composer run-script test 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.idea 3 | 4 | .phpunit.result.cache 5 | .phpunit.cache 6 | /phpunit.xml 7 | 8 | composer.lock 9 | 10 | .idea 11 | vendor 12 | .php-cs-fixer.cache 13 | .phikl.cache 14 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | exclude(['vendor']) 5 | ->in(__DIR__) 6 | ; 7 | 8 | $config = new PhpCsFixer\Config(); 9 | 10 | return $config->setRules([ 11 | '@Symfony' => true, 12 | '@Symfony:risky' => true, 13 | 'protected_to_private' => false, 14 | 'nullable_type_declaration_for_default_null_value' => ['use_nullable_type_declaration' => true], 15 | 'get_class_to_class_keyword' => true, 16 | 'normalize_index_brace' => true, 17 | 'trim_array_spaces' => true, 18 | 'no_multiple_statements_per_line' => true, 19 | '@DoctrineAnnotation' => true, 20 | 'yoda_style' => false, 21 | 'array_indentation' => true, 22 | 'blank_line_before_statement' => ['statements' => ['break', 'case', 'continue', 'declare', 'default', 'exit', 'goto', 'include', 'include_once', 'phpdoc', 'require', 'require_once', 'return', 'switch', 'throw', 'try', 'yield', 'yield_from']], 23 | 'type_declaration_spaces' => true, 24 | 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], 25 | 'native_function_invocation' => ['include' => ['@compiler_optimized'], 'scope' => 'namespaced', 'strict' => false], 26 | 'phpdoc_to_comment' => false, 27 | ]) 28 | ->setFinder($finder); 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alexandre Daubois 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 🥒 Phikl - Apple's Pkl Bridge for PHP 2 | ===================================== 3 | 4 | [![PHPUnit](https://github.com/alexandre-daubois/phikl/actions/workflows/ci.yaml/badge.svg)](https://github.com/alexandre-daubois/phikl/actions/workflows/ci.yaml) 5 | 6 | Phikl (pronounced "_fickle_") is a PHP binding for Apple's PKL language. This library uses the official PKL CLI tool from Apple and 7 | provides a PHP interface to it. 8 | 9 | ## Installation 10 | 11 | You can install this library using composer: 12 | 13 | ```bash 14 | composer require alexandre-daubois/phikl 15 | ``` 16 | 17 | The CLI tool must be installed on your system. You can either install it manually and set the `PKL_CLI_BIN` 18 | environment variable to the path of the binary or use the `install` subcommand of the `pkl` command to download 19 | the latest supported version of the PKL CLI tool into the `vendor/bin` directory. 20 | 21 | ```bash 22 | vendor/bin/phikl install 23 | ``` 24 | 25 | You can also set the download location by using the `--location` option: 26 | 27 | ```bash 28 | vendor/bin/phikl install --location=/usr/local/bin 29 | ``` 30 | 31 | If you do so, you must set the `PKL_CLI_BIN` environment variable to the path of the binary. 32 | 33 | ## Usage 34 | 35 | ⚠️ If you plan to use this tool in production, it is highly recommended [to cache the PKL modules](#Caching). 36 | 37 | ### Using the CLI tool 38 | 39 | This package offers a CLI tool to interact with the PKL CLI tool. You can use the `phikl` command to interact with the 40 | PKL CLI tool, among other things. 41 | 42 | Here are some examples of how to use the `phikl` command: 43 | 44 | ```bash 45 | # Install the PKL CLI tool 46 | vendor/bin/phikl install 47 | 48 | # Update/Force install the last supported PKL CLI tool 49 | vendor/bin/phikl update 50 | 51 | # Print current PKL CLI tool version 52 | vendor/bin/phikl version 53 | 54 | # Evaluate one or many PKL file 55 | vendor/bin/phikl eval config/simple.pkl config/nested.pkl 56 | ``` 57 | 58 | ### Using Pkl in PHP 59 | 60 | The main way to use this library is to evaluate PKL code. You can do this by using the `evaluate` method of the 61 | `Pkl` class. 62 | 63 | #### Basic Usage with PklModule 64 | 65 | Let's say you have the following PKL code: 66 | 67 | ```pkl 68 | /// config/simple.pkl 69 | 70 | name = "Pkl: Configure your Systems in New Ways" 71 | attendants = 100 72 | isInteractive = true 73 | amountLearned = 13.37 74 | ``` 75 | 76 | You can evaluate this code like this: 77 | 78 | ```php 79 | use Phikl\Pkl; 80 | 81 | $module = Pkl::eval('config/simple.pkl'); 82 | 83 | // you can then interact with the module 84 | echo $module->get('name'); // Pkl: Configure your Systems in New Ways 85 | echo $module->get('attendants'); // 100 86 | echo $module->get('isInteractive'); // true 87 | echo $module->get('amountLearned'); // 13.37 88 | ``` 89 | 90 | This also works with nested modules: 91 | 92 | ```pkl 93 | /// config/nested.pkl 94 | 95 | woodPigeon { 96 | name = "Common wood pigeon" 97 | diet = "Seeds" 98 | taxonomy { 99 | species = "Columba palumbus" 100 | } 101 | } 102 | ``` 103 | 104 | ```php 105 | use Phikl\Pkl; 106 | 107 | $module = Pkl::eval('config/nested.pkl'); 108 | 109 | // you can then interact with the module 110 | echo $module->get('woodPigeon')->get('name'); // Common wood pigeon 111 | echo $module->get('woodPigeon')->get('diet'); // Seeds 112 | echo $module->get('woodPigeon')->get('taxonomy')->get('species'); // Columba palumbus 113 | ``` 114 | 115 | #### Cast to other types 116 | 117 | You can cast the values to other types using the `cast` method with a class 118 | representing your data. Let's take the following PKL code: 119 | 120 | ```pkl 121 | myUser { 122 | id = 1 123 | name = "John Doe" 124 | address { 125 | street = "123 Main St" 126 | city = "Springfield" 127 | state = "IL" 128 | zip = "62701" 129 | } 130 | } 131 | ``` 132 | 133 | You can cast this to a `User` class like this: 134 | 135 | ```php 136 | use Phikl\Pkl; 137 | 138 | class User 139 | { 140 | public int $id; 141 | public string $name; 142 | public Address $address; 143 | } 144 | 145 | class Address 146 | { 147 | public string $street; 148 | public string $city; 149 | public string $state; 150 | public string $zip; 151 | } 152 | 153 | $module = Pkl::eval('config/user.pkl'); 154 | $user = $module->get('myUser')->cast(User::class); 155 | ``` 156 | 157 | You can also pass `User::class` as the second argument to the `eval` method. This will automatically cast the module to 158 | the given class. Beware that it returns an array indexed by the PKL instance name: 159 | 160 | ```php 161 | use Phikl\Pkl; 162 | 163 | // ... 164 | 165 | $user = Pkl::eval('config/user.pkl', User::class)['myUser']; 166 | ``` 167 | 168 | #### The `PklProperty` Attribute 169 | 170 | You can use the `PklProperty` attribute to specify the name of the property in the PKL file. This is useful when the 171 | property name in the PKL file is different from the property name in the PHP class. Let's take the following PKL code: 172 | 173 | ```pkl 174 | myUser { 175 | id = 1 176 | name = "John Doe" 177 | address { 178 | street = "123 Main St" 179 | city = "Springfield" 180 | state = "IL" 181 | zip = "62701" 182 | } 183 | } 184 | ``` 185 | 186 | You can define a `User` class like this: 187 | 188 | ```php 189 | use Phikl\PklProperty; 190 | 191 | class User 192 | { 193 | #[PklProperty('id')] 194 | public int $userId; 195 | 196 | #[PklProperty('name')] 197 | public string $userName; 198 | 199 | public Address $address; 200 | } 201 | ``` 202 | 203 | When casting, the `PklProperty` attribute will be used to map the property name in the PKL file to the property 204 | name in the PHP class. 205 | 206 | ## Caching 207 | 208 | You can (**and should**) cache the PKL modules to improve performance. This is especially useful when evaluating the same PKL file 209 | multiple times. 210 | 211 | **⚠️ Using Phikl with the cache avoids the PKL CLI tool to be executed to evaluate modules and should be done when deploying your application for better performances.** 212 | 213 | ### Warmup the Cache 214 | 215 | You can use the `warmup` command to dump the PKL modules to a cache file by default. Phikl will then use the cache file automatically when evaluating a PKL file. If the PKL file is not found in the cache, Phikl will evaluate the PKL file on the go. 216 | 217 | Phikl will go through all `.pkl` files of your project and dump them to the cache file. 218 | 219 | Here's an example of how to use the `warmup` command: 220 | 221 | ```bash 222 | vendor/bin/phikl warmup 223 | 224 | # you can also specify the file if you want to use a custom location 225 | # don't forget to set the `PHIKL_CACHE_FILE` environment variable 226 | vendor/bin/phikl warmup --cache-file=cache/pkl.cache 227 | ``` 228 | 229 | If you need to validate a cache file, you can do so by using the `validate-cache` command: 230 | 231 | ```bash 232 | vendor/bin/phikl validate-cache 233 | 234 | # optionally, set the `PHIKL_CACHE_FILE` environment variable 235 | # or use the `--cache-file` option 236 | vendor/bin/phikl validate-cache --cache-file=.cache/.phikl 237 | ``` 238 | 239 | Here are a few things to note about Phikl cache: 240 | 241 | - You can disable the cache by calling `Pkl::toggleCache(false)`, which is useful for development but highly discouraged in production 242 | - Phikl will automatically refresh the cache if a PKL module is modified since last warmup 243 | - Any corrupted cache entry will be automatically refreshed 244 | 245 | ### Cache Backends 246 | 247 | If you have your own cache system, you can use the `Pkl::setCache()` method to set the cache system to use. You can pass it any instance of compliant PSR-16 cache system implementing `Psr\SimpleCache\CacheInterface`. This is useful you want to use, for example, a Redis server as a cache system for your Pkl modules. 248 | 249 | Phikl comes with the following cache backends: 250 | 251 | * `PersistentCache`, which is the default one used by Phikl. It uses a file to store the cache ; 252 | * `ApcuCacheAdapter`, which uses the APCu extension to store the cache in memory ; 253 | * `MemcachedCacheAdapter`, which uses the Memcached extension to store the cache in memory. 254 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alexandre-daubois/phikl", 3 | "type": "library", 4 | "description": "Apple's Pkl language bridge for PHP", 5 | "keywords": ["pkl", "apple", "pickle", "language", "configuration", "configuration-as-code"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Alexandre Daubois", 10 | "email": "alex.daubois@gmail.com" 11 | } 12 | ], 13 | "minimum-stability": "stable", 14 | "prefer-stable": true, 15 | "require": { 16 | "php": ">=8.2", 17 | "psr/simple-cache": "^3.0", 18 | "symfony/console": "^6.4|^7.0", 19 | "symfony/finder": "^6.4|^7.0", 20 | "symfony/process": "^6.4|^7.0" 21 | }, 22 | "config": { 23 | "optimize-autoloader": true, 24 | "preferred-install": { 25 | "*": "dist" 26 | }, 27 | "sort-packages": true 28 | }, 29 | "suggest": { 30 | "ext-apcu": "To use the APCu cache backend", 31 | "ext-curl": "To be able to install Pkl CLI tool with the `install` command", 32 | "ext-memcached": "To use the Memcached cache backend" 33 | }, 34 | "bin": [ 35 | "phikl" 36 | ], 37 | "autoload": { 38 | "psr-4": { "Phikl\\": "src", "Phikl\\Tests\\": "tests" } 39 | }, 40 | "scripts": { 41 | "test": "./vendor/bin/phpunit --display-warnings", 42 | "cs": "./vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php --diff --allow-risky=yes", 43 | "stan": "vendor/bin/phpstan analyse src tests -l 8" 44 | }, 45 | "require-dev": { 46 | "friendsofphp/php-cs-fixer": "^3.58.1", 47 | "phpstan/phpstan": "^1.11.4", 48 | "phpunit/phpunit": "^10.5.20" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /phikl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | if (PHP_VERSION_ID < 80200) { 12 | fwrite( 13 | STDERR, 14 | sprintf("You must use at least PHP 8.2.0, and you're using PHP %s. Please consider upgrading your PHP binary.", PHP_VERSION) 15 | ); 16 | 17 | exit(1); 18 | } 19 | 20 | if (isset($GLOBALS['_composer_autoload_path'])) { 21 | define('LOCAL_COMPOSER_INSTALL', $GLOBALS['_composer_autoload_path']); 22 | } else { 23 | foreach ([__DIR__.'/../../autoload.php', __DIR__.'/../vendor/autoload.php', __DIR__.'/vendor/autoload.php'] as $file) { 24 | if (file_exists($file)) { 25 | define('LOCAL_COMPOSER_INSTALL', $file); 26 | 27 | break; 28 | } 29 | } 30 | 31 | unset($file); 32 | } 33 | 34 | if (!defined('LOCAL_COMPOSER_INSTALL')) { 35 | fwrite( 36 | STDERR, 37 | 'Composer has not been setup. Please consider running `composer install`.'. PHP_EOL 38 | ); 39 | 40 | exit(1); 41 | } 42 | 43 | require LOCAL_COMPOSER_INSTALL; 44 | 45 | use Phikl\Internal\Command\Runner; 46 | use Symfony\Component\Console\Command\Command; 47 | use Symfony\Component\Console\Input\InputArgument; 48 | use Symfony\Component\Console\Input\InputInterface; 49 | use Symfony\Component\Console\Input\InputOption; 50 | use Symfony\Component\Console\Output\OutputInterface; 51 | use Symfony\Component\Console\SingleCommandApplication; 52 | 53 | (new SingleCommandApplication()) 54 | ->setName('Phikl') 55 | ->addArgument('subcommand', InputArgument::REQUIRED, 'The subcommand to run') 56 | ->addArgument('args', InputArgument::IS_ARRAY, 'The arguments to pass to the subcommand') 57 | ->addOption('location', 'l', InputOption::VALUE_REQUIRED, 'Location to install pkl cli when installing it', 'vendor/bin') 58 | ->addOption('cache-file', 'c', InputOption::VALUE_REQUIRED, 'The cache file to use') 59 | ->setCode(function (InputInterface $input, OutputInterface $output): int { 60 | try { 61 | return Runner::run($input, $output); 62 | } catch (Throwable $e) { 63 | $output->writeln(''.$e->getMessage().''); 64 | 65 | return Command::FAILURE; 66 | } 67 | }) 68 | ->run(); 69 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests 15 | 16 | 17 | 18 | 19 | 20 | src 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Attribute/PklProperty.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Phikl\Attribute; 11 | 12 | #[\Attribute(\Attribute::TARGET_PROPERTY)] 13 | class PklProperty 14 | { 15 | public function __construct(public string $name) 16 | { 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Cache/Adapter/AbstractRemoteCacheAdapter.php: -------------------------------------------------------------------------------- 1 | $keys 12 | * 13 | * @return array 14 | */ 15 | public function getMultiple(iterable $keys, mixed $default = null): array 16 | { 17 | $entries = []; 18 | foreach ($keys as $key) { 19 | $entries[$key] = $this->get($key, $default); 20 | } 21 | 22 | return $entries; 23 | } 24 | 25 | /** 26 | * @param iterable $values 27 | */ 28 | public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool 29 | { 30 | foreach ($values as $key => $value) { 31 | if (!$this->set($key, $value, $ttl)) { 32 | return false; 33 | } 34 | } 35 | 36 | return true; 37 | } 38 | 39 | /** 40 | * @param iterable $keys 41 | */ 42 | public function deleteMultiple(iterable $keys): bool 43 | { 44 | $success = true; 45 | foreach ($keys as $key) { 46 | if (!$this->delete($key)) { 47 | $success = false; 48 | } 49 | } 50 | 51 | return $success; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Cache/Adapter/ApcuCacheAdapter.php: -------------------------------------------------------------------------------- 1 | add($ttl)->format('U') - \time()) 57 | : ($ttl ?? 0) 58 | ); 59 | } 60 | 61 | public function delete(string $key): bool 62 | { 63 | return apcu_delete($key); 64 | } 65 | 66 | /** 67 | * Caution, this method will clear the entire cache, not just the cache for this application. 68 | */ 69 | public function clear(): bool 70 | { 71 | return apcu_clear_cache(); 72 | } 73 | 74 | public function has(string $key): bool 75 | { 76 | return apcu_exists($key); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Cache/Adapter/MemcachedCacheAdapter.php: -------------------------------------------------------------------------------- 1 | $servers 13 | * @param string $persistentId the persistent_id is used to create a unique connection 14 | * pool for the specified servers 15 | */ 16 | public function __construct(MemcachedServer|array $servers, string $persistentId = 'phikl') 17 | { 18 | if (!\extension_loaded('memcached')) { 19 | throw new \RuntimeException('Memcached extension is not loaded'); 20 | } 21 | 22 | $servers = \is_array($servers) ? $servers : [$servers]; 23 | 24 | $this->memcached = new \Memcached($persistentId); 25 | foreach ($servers as $server) { 26 | $this->memcached->addServer($server->host, $server->port); 27 | } 28 | } 29 | 30 | /** 31 | * @param non-empty-string $key 32 | */ 33 | public function get(string $key, mixed $default = null): ?Entry 34 | { 35 | if ($default !== null && !$default instanceof Entry) { 36 | throw new \InvalidArgumentException('Default value must be null or an instance of Entry'); 37 | } 38 | 39 | $entry = $this->memcached->get($key); 40 | if ($this->memcached->getResultCode() === \Memcached::RES_NOTFOUND) { 41 | return $default; 42 | } 43 | 44 | $entry = @unserialize($entry); 45 | if ($entry === false) { 46 | return $default; 47 | } 48 | 49 | return $entry; 50 | } 51 | 52 | public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool 53 | { 54 | if (!$value instanceof Entry) { 55 | return false; 56 | } 57 | 58 | return $this->memcached->set( 59 | $key, 60 | serialize($value), 61 | $ttl instanceof \DateInterval ? 62 | (int) ((new \DateTimeImmutable())->add($ttl)->format('U') - \time()) 63 | : ($ttl ?? 0) 64 | ); 65 | } 66 | 67 | public function delete(string $key): bool 68 | { 69 | return $this->memcached->delete($key); 70 | } 71 | 72 | /** 73 | * Caution, this method will clear the entire cache, not just the cache for this application. 74 | */ 75 | public function clear(): bool 76 | { 77 | return $this->memcached->flush(); 78 | } 79 | 80 | public function has(string $key): bool 81 | { 82 | return $this->memcached->get($key) !== false; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Cache/Adapter/MemcachedServer.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Phikl\Cache; 11 | 12 | class Entry 13 | { 14 | public function __construct( 15 | public string $content, 16 | public string $hash, 17 | public int $timestamp, 18 | ) { 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Cache/PersistentCache.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Phikl\Cache; 11 | 12 | use Phikl\Exception\CorruptedCacheException; 13 | use Phikl\Exception\EmptyCacheException; 14 | use Psr\SimpleCache\CacheInterface; 15 | 16 | /** 17 | * Simple implementation of the PSR-16 CacheInterface using a file for 18 | * Pkl modules evaluation cache. 19 | */ 20 | final class PersistentCache implements CacheInterface 21 | { 22 | private const DEFAULT_CACHE_FILE = '.phikl.cache'; 23 | 24 | public function __construct(private ?string $cacheFile = null) 25 | { 26 | } 27 | 28 | /** 29 | * @var array|null 30 | */ 31 | private ?array $entries = null; 32 | 33 | /** 34 | * This gets an entry from the cache. If the entry is not found, it returns the default value. 35 | * If the default value is not an instance of Entry, it throws an exception. 36 | * If the entry is found, but it is corrupted or stalled, it is refreshed. 37 | * 38 | * @throws \InvalidArgumentException 39 | */ 40 | public function get(string $key, mixed $default = null): ?Entry 41 | { 42 | $entry = $this->entries[$key] ?? null; 43 | $isHit = $entry !== null; 44 | 45 | if ($isHit) { 46 | $actualHash = \md5($entry->content); 47 | $mtime = @\filemtime($key); 48 | 49 | if ($entry->hash !== $actualHash || $mtime === false || $entry->timestamp < $mtime) { 50 | // cache is either corrupted or outdated, refresh it 51 | unset($this->entries[$key]); 52 | 53 | $entry = new Entry($entry->content, $actualHash, \time()); 54 | $this->set($key, $entry); 55 | 56 | $this->save(); 57 | } 58 | } 59 | 60 | if ($default !== null && !$default instanceof Entry) { 61 | throw new \InvalidArgumentException('Default value must be an instance of Entry'); 62 | } 63 | 64 | return $entry ?? $default; 65 | } 66 | 67 | /** 68 | * This sets an entry in the cache. If the value is not an instance of Entry, it returns false. 69 | */ 70 | public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool 71 | { 72 | if (!$value instanceof Entry) { 73 | return false; 74 | } 75 | 76 | $this->entries[$key] = $value; 77 | 78 | return true; 79 | } 80 | 81 | /** 82 | * Deletes an entry from the cache. 83 | */ 84 | public function delete(string $key): true 85 | { 86 | unset($this->entries[$key]); 87 | 88 | return true; 89 | } 90 | 91 | /** 92 | * This gets multiple entries from the cache. If an entry is not found, it returns the default value. 93 | * The default value must be an instance of Entry. 94 | * 95 | * @return array 96 | */ 97 | public function getMultiple(iterable $keys, mixed $default = null): array 98 | { 99 | $entries = []; 100 | foreach ($keys as $key) { 101 | $entries[$key] = $this->get($key, $default); 102 | } 103 | 104 | return $entries; 105 | } 106 | 107 | /** 108 | * Sets multiple entries in the cache. If a value is not an instance of Entry, it returns false. Ttl is ignored. 109 | * 110 | * @param iterable $values 111 | */ 112 | public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool 113 | { 114 | foreach ($values as $key => $value) { 115 | if (!$this->set($key, $value, $ttl)) { 116 | return false; 117 | } 118 | } 119 | 120 | return true; 121 | } 122 | 123 | /** 124 | * Deletes multiple entries from the cache. 125 | */ 126 | public function deleteMultiple(iterable $keys): bool 127 | { 128 | foreach ($keys as $key) { 129 | $this->delete($key); 130 | } 131 | 132 | return true; 133 | } 134 | 135 | /** 136 | * Whether an entry exists in the cache. 137 | */ 138 | public function has(string $key): bool 139 | { 140 | return \array_key_exists($key, $this->entries ?? []); 141 | } 142 | 143 | /** 144 | * Clears the entire cache and always returns true. 145 | */ 146 | public function clear(): true 147 | { 148 | $this->entries = null; 149 | 150 | return true; 151 | } 152 | 153 | /** 154 | * Loads the cache from the file, guessed from the defined file, the environment variables if any, or 155 | * from the default file. 156 | */ 157 | public function load(): void 158 | { 159 | $cacheFile = $this->getCacheFile(); 160 | if (!file_exists($cacheFile)) { 161 | return; 162 | } 163 | 164 | $content = file_get_contents($cacheFile); 165 | if ($content === false) { 166 | return; 167 | } 168 | 169 | $unserialized = @unserialize($content, ['allowed_classes' => [self::class, Entry::class]]); 170 | if ($unserialized === false) { 171 | // the cache is unreadable, erase everything 172 | $this->entries = null; 173 | @unlink($cacheFile); 174 | 175 | return; 176 | } 177 | 178 | $this->entries = $unserialized; 179 | } 180 | 181 | /** 182 | * Saves the cache to the file, guessed from the defined file, the environment variables if any, or 183 | * from the default file. 184 | */ 185 | public function save(): void 186 | { 187 | file_put_contents($this->getCacheFile(), serialize($this->entries)); 188 | } 189 | 190 | /** 191 | * @throws EmptyCacheException 192 | * @throws CorruptedCacheException 193 | */ 194 | public function validate(): void 195 | { 196 | if ($this->entries === null) { 197 | $this->load(); 198 | } 199 | 200 | if ($this->entries === null) { 201 | throw new EmptyCacheException($this->getCacheFile()); 202 | } 203 | 204 | foreach ($this->entries as $entry) { 205 | if ($entry->hash !== \md5($entry->content)) { 206 | throw new CorruptedCacheException($this->getCacheFile()); 207 | } 208 | } 209 | } 210 | 211 | /** 212 | * Guesses the cache file by falling back to the environment variables or the default file. 213 | */ 214 | public function getCacheFile(): string 215 | { 216 | return $this->cacheFile ?? $_ENV['PHIKL_CACHE_FILE'] ?? $_SERVER['PHIKL_CACHE_FILE'] ?? self::DEFAULT_CACHE_FILE; 217 | } 218 | 219 | /** 220 | * Sets an explicit cache file, which takes precedence over the environment variables. 221 | */ 222 | public function setCacheFile(string $cacheFile): void 223 | { 224 | $this->cacheFile = $cacheFile; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/Exception/CorruptedCacheException.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Phikl\Exception; 11 | 12 | class CorruptedCacheException extends \RuntimeException 13 | { 14 | public function __construct(string $cacheFile) 15 | { 16 | parent::__construct(sprintf('The cache file "%s" seems corrupted and should be generated again with the `phikl warmup` command.', $cacheFile)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Exception/EmptyCacheException.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Phikl\Exception; 11 | 12 | class EmptyCacheException extends \RuntimeException 13 | { 14 | public function __construct(string $cacheFile) 15 | { 16 | parent::__construct(sprintf('The cache file "%s" is empty or does not exist and should be generated again with the `phikl warmup` command.', $cacheFile)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Exception/PklCliAlreadyDownloadedException.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Phikl\Exception; 11 | 12 | class PklCliAlreadyDownloadedException extends \RuntimeException 13 | { 14 | } 15 | -------------------------------------------------------------------------------- /src/Internal/Command/Runner.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Phikl\Internal\Command; 11 | 12 | use Phikl\Cache\PersistentCache; 13 | use Phikl\Exception\CorruptedCacheException; 14 | use Phikl\Exception\EmptyCacheException; 15 | use Phikl\Internal\PklDownloader; 16 | use Phikl\Pkl; 17 | use Symfony\Component\Console\Command\Command; 18 | use Symfony\Component\Console\Input\InputInterface; 19 | use Symfony\Component\Console\Output\OutputInterface; 20 | use Symfony\Component\Console\Style\SymfonyStyle; 21 | use Symfony\Component\Process\Exception\ProcessFailedException; 22 | 23 | /** 24 | * @internal 25 | */ 26 | final class Runner 27 | { 28 | public static function run(InputInterface $input, OutputInterface $output): int 29 | { 30 | if ($input->getArgument('subcommand') === 'install') { 31 | return self::install($input, $output); 32 | } elseif ($input->getArgument('subcommand') === 'update') { 33 | return self::install($input, $output, true); 34 | } elseif ($input->getArgument('subcommand') === 'version') { 35 | return self::version($input, $output); 36 | } elseif ($input->getArgument('subcommand') === 'eval') { 37 | return self::eval($input, $output); 38 | } elseif ($input->getArgument('subcommand') === 'warmup') { 39 | return self::warmup($input, $output); 40 | } elseif ($input->getArgument('subcommand') === 'validate-cache') { 41 | return self::validateCache($input, $output); 42 | } 43 | 44 | return Command::INVALID; 45 | } 46 | 47 | private static function install(InputInterface $input, OutputInterface $output, bool $force = false): int 48 | { 49 | $pickleDownloader = new PklDownloader(); 50 | $io = new SymfonyStyle($input, $output); 51 | 52 | try { 53 | $location = rtrim($input->getOption('location'), '/'); 54 | if (!$pickleDownloader->alreadyDownloaded($location) || $force) { 55 | $io->comment('Downloading Pkl CLI...'); 56 | 57 | $pickleDownloader->download($io, $location, $force); 58 | } else { 59 | $io->success(sprintf('Pkl CLI is already installed in %s.', $location)); 60 | } 61 | 62 | try { 63 | $io->success('Running '.Pkl::binaryVersion($location.'/pkl')); 64 | } catch (ProcessFailedException) { 65 | throw new \RuntimeException('Pkl CLI could not be installed, make sure the location is in your PATH.'); 66 | } 67 | } catch (\Exception $e) { 68 | $io->error('Pkl failed: '.$e->getMessage()); 69 | 70 | return Command::FAILURE; 71 | } 72 | 73 | return Command::SUCCESS; 74 | } 75 | 76 | private static function version(InputInterface $input, OutputInterface $output): int 77 | { 78 | $io = new SymfonyStyle($input, $output); 79 | 80 | try { 81 | $io->success('Running '.Pkl::binaryVersion()); 82 | } catch (ProcessFailedException) { 83 | throw new \RuntimeException('Pkl CLI could not be installed, make sure the location is in your PATH.'); 84 | } 85 | 86 | return Command::SUCCESS; 87 | } 88 | 89 | private static function eval(InputInterface $input, OutputInterface $output): int 90 | { 91 | $io = new SymfonyStyle($input, $output); 92 | 93 | try { 94 | $io->writeln(Pkl::rawEval(...$input->getArgument('args'))); 95 | } catch (\Exception $e) { 96 | $io->error('Pkl failed: '.$e->getMessage()); 97 | 98 | return Command::FAILURE; 99 | } 100 | 101 | return Command::SUCCESS; 102 | } 103 | 104 | private static function warmup(InputInterface $input, OutputInterface $output): int 105 | { 106 | $io = new SymfonyStyle($input, $output); 107 | $cacheFile = $input->getOption('cache-file') ?? self::guessCacheFile(); 108 | 109 | try { 110 | $count = Pkl::warmup($cacheFile); 111 | 112 | $io->success(sprintf('%d files warmed up to "%s" cache file.', $count, $cacheFile)); 113 | 114 | if ($cacheFile !== '.phikl.cache') { 115 | $io->caution('Make sure to declare the PHIKL_CACHE_FILE environment variable to use the cache file.'); 116 | } 117 | } catch (\Exception $e) { 118 | $io->error('Pkl failed: '.$e->getMessage()); 119 | 120 | return Command::FAILURE; 121 | } 122 | 123 | return Command::SUCCESS; 124 | } 125 | 126 | private static function validateCache(InputInterface $input, OutputInterface $output): int 127 | { 128 | $io = new SymfonyStyle($input, $output); 129 | $cacheFile = $input->getOption('cache-file') ?? self::guessCacheFile(); 130 | 131 | if (!file_exists($cacheFile)) { 132 | $io->warning(sprintf('Cache file "%s" does not exist, it can be generated with the `phikl warmup` command.', $cacheFile)); 133 | 134 | return Command::FAILURE; 135 | } 136 | 137 | try { 138 | $cache = new PersistentCache(); 139 | $cache->setCacheFile($cacheFile); 140 | $cache->validate(); 141 | 142 | $io->success(sprintf('Cache file "%s" is valid.', $cacheFile)); 143 | } catch (CorruptedCacheException $e) { 144 | $io->error($e->getMessage()); 145 | 146 | return Command::FAILURE; 147 | } catch (EmptyCacheException $e) { 148 | $io->warning($e->getMessage()); 149 | } 150 | 151 | return Command::SUCCESS; 152 | } 153 | 154 | private static function guessCacheFile(): string 155 | { 156 | return $_ENV['PHIKL_CACHE_FILE'] ?? $_SERVER['PHIKL_CACHE_FILE'] ?? '.phikl.cache'; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/Internal/PklDownloader.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Phikl\Internal; 11 | 12 | use Phikl\Exception\PklCliAlreadyDownloadedException; 13 | use Symfony\Component\Console\Helper\ProgressBar; 14 | use Symfony\Component\Console\Style\SymfonyStyle; 15 | 16 | /** 17 | * @internal 18 | */ 19 | final class PklDownloader 20 | { 21 | private const PKL_CLI_VERSION = '0.25.3'; 22 | 23 | public function __construct() 24 | { 25 | if (!\extension_loaded('curl')) { 26 | throw new \RuntimeException('The curl extension is required to download the Pkl CLI. You can either install it or download the Pkl CLI manually.'); 27 | } 28 | } 29 | 30 | public function alreadyDownloaded(string $location = 'vendor/bin'): bool 31 | { 32 | return file_exists($location.'/pkl'); 33 | } 34 | 35 | public function download(SymfonyStyle $io, string $location = 'vendor/bin', bool $force = false): void 36 | { 37 | if ($this->alreadyDownloaded($location) && !$force) { 38 | throw new PklCliAlreadyDownloadedException('Pkl CLI is already installed.'); 39 | } 40 | 41 | if ($this->is32Bit()) { 42 | throw new \RuntimeException('32-bit systems are not supported by Pkl CLI.'); 43 | } 44 | 45 | $downloadUrl = $this->buildDownloadUrl(); 46 | $pklCliPath = $location.\DIRECTORY_SEPARATOR.'pkl'; 47 | 48 | $this->curlUrlToFile($downloadUrl, $location, 'pkl', $io); 49 | 50 | if ($this->isMacOs() || $this->isLinux()) { 51 | chmod($pklCliPath, 0755); 52 | } 53 | 54 | $io->comment(sprintf('Pkl CLI downloaded to %s', $pklCliPath)); 55 | 56 | if ($location !== 'vendor/bin') { 57 | $io->caution('You used a custom location for the Pkl CLI. Make sure to add the location to set the PKL_CLI_BIN environment variable.'); 58 | } 59 | 60 | if (str_ends_with($downloadUrl, '.jar')) { 61 | $io->warning('You are using the Java version of the Pkl CLI. Make sure the JDK is installed and present in your PATH.'); 62 | } 63 | } 64 | 65 | private function curlUrlToFile(string $url, string $location, string $fileName, SymfonyStyle $io): void 66 | { 67 | $curlHandle = \curl_init($url); 68 | \assert($curlHandle !== false); 69 | 70 | $file = \fopen($location.\DIRECTORY_SEPARATOR.$fileName, 'w'); 71 | 72 | if (!is_writable($location) && !mkdir($location, 0755, true) && !is_dir($location) || $file === false) { 73 | throw new \RuntimeException(sprintf('Pkl CLI could not be installed to %s, ensure the location is writable.', $location)); 74 | } 75 | 76 | $progressBar = new ProgressBar($io); 77 | 78 | \curl_setopt($curlHandle, \CURLOPT_FILE, $file); 79 | \curl_setopt($curlHandle, \CURLOPT_FOLLOWLOCATION, true); 80 | \curl_setopt($curlHandle, \CURLOPT_NOPROGRESS, false); 81 | \curl_setopt($curlHandle, \CURLOPT_PROGRESSFUNCTION, function ( 82 | mixed $resource, 83 | int $downloadSize, 84 | int $downloaded, 85 | int $uploadSize, 86 | int $uploaded 87 | ) use ($progressBar): void { 88 | if ($downloadSize > 0) { 89 | $progressBar->setMaxSteps($downloadSize); 90 | $progressBar->setProgress($downloaded); 91 | } 92 | }); 93 | 94 | \curl_exec($curlHandle); 95 | 96 | if (\curl_errno($curlHandle)) { 97 | \fclose($file); 98 | 99 | throw new \RuntimeException(\curl_error($curlHandle)); 100 | } 101 | 102 | \fclose($file); 103 | \curl_close($curlHandle); 104 | } 105 | 106 | private function buildDownloadUrl(): string 107 | { 108 | $downloadUrl = 'https://github.com/apple/pkl/releases/download/'.self::PKL_CLI_VERSION.'/pkl-'; 109 | if ($this->isMacOs()) { 110 | return $downloadUrl.($this->isArmArch() ? 'macos-aarch64' : 'macos-amd64'); 111 | } elseif ($this->isLinux()) { 112 | return $downloadUrl.($this->isArmArch() ? 'linux-aarch64' : 'linux-amd64'); 113 | } 114 | 115 | return 'https://repo1.maven.org/maven2/org/pkl-lang/pkl-cli-java/'.self::PKL_CLI_VERSION.'/pkl-cli-java-'.self::PKL_CLI_VERSION.'.jar'; 116 | } 117 | 118 | private function isArmArch(): bool 119 | { 120 | return str_contains(strtolower(php_uname('m')), 'arm'); 121 | } 122 | 123 | private function isMacOs(): bool 124 | { 125 | return str_contains(strtolower(php_uname('s')), 'darwin'); 126 | } 127 | 128 | private function isLinux(): bool 129 | { 130 | return str_contains(strtolower(php_uname('s')), 'linux'); 131 | } 132 | 133 | private function is32Bit(): bool 134 | { 135 | return \PHP_INT_SIZE === 4; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Pkl.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Phikl; 11 | 12 | use Phikl\Cache\Entry; 13 | use Phikl\Cache\PersistentCache; 14 | use Psr\SimpleCache\CacheInterface; 15 | use Symfony\Component\Finder\Finder; 16 | use Symfony\Component\Process\Exception\ProcessFailedException; 17 | use Symfony\Component\Process\Process; 18 | 19 | /** 20 | * This is the main class to interact 21 | * with the PKL CLI tool. 22 | */ 23 | class Pkl 24 | { 25 | private static string $executable; 26 | private static ?CacheInterface $cache = null; 27 | private static bool $cacheEnabled = true; 28 | 29 | /** 30 | * @template T of object 31 | * 32 | * @param class-string $toClass 33 | * 34 | * All properties will be cast to `$toClass` class, if different 35 | * from PklModule. For example, the following module will 36 | * be cast to two `$toClass` instances: 37 | * 38 | * ```pkl 39 | * user1 { 40 | * id: 1 41 | * } 42 | * 43 | * user2 { 44 | * id: 2 45 | * } 46 | * ``` 47 | * 48 | * @return array|PklModule 49 | */ 50 | public static function eval(string $module, string $toClass = PklModule::class): array|PklModule 51 | { 52 | self::$cache ??= new PersistentCache(); 53 | if (self::$cache instanceof PersistentCache && self::$cacheEnabled) { 54 | self::$cache->load(); 55 | } 56 | 57 | if ((null === $entry = self::$cache->get($module)) || !self::$cacheEnabled) { 58 | self::initExecutable(); 59 | 60 | $process = new Process([self::$executable, 'eval', '-f', 'json', $module]); 61 | 62 | try { 63 | $process->mustRun(); 64 | } catch (ProcessFailedException) { 65 | throw new \RuntimeException($process->getErrorOutput()); 66 | } 67 | 68 | $content = \trim($process->getOutput()); 69 | $entry = new Entry($content, \md5($content), \time()); 70 | 71 | if (self::$cacheEnabled) { 72 | // if we're in this condition, then it is a cache miss, thus the entry is 73 | // automatically refreshed/added to the cache 74 | if (self::$cache->set($module, $entry) && self::$cache instanceof PersistentCache) { 75 | self::$cache->save(); 76 | } 77 | } 78 | } 79 | 80 | /** @var PklModule $module */ 81 | $decoded = \json_decode($entry->content, true); 82 | $module = new PklModule(); 83 | foreach ($decoded as $key => $value) { 84 | $module->__set($key, $value); 85 | } 86 | 87 | if ($toClass === PklModule::class) { 88 | return $module; 89 | } 90 | 91 | $instances = []; 92 | foreach ($module->keys() as $key) { 93 | if (!$module->get($key) instanceof PklModule) { 94 | throw new \RuntimeException(sprintf('The module "%s" is not a PklModule instance.', $key)); 95 | } 96 | 97 | $instances[$key] = $module->get($key)->cast($toClass); 98 | } 99 | 100 | return $instances; 101 | } 102 | 103 | /** 104 | * Returns the version of the PKL CLI tool. 105 | */ 106 | public static function binaryVersion(?string $binPath = null): string 107 | { 108 | if ($binPath === null) { 109 | self::initExecutable(); 110 | } 111 | 112 | $process = new Process([$binPath ?? self::$executable, '--version']); 113 | $process->mustRun(); 114 | 115 | return trim($process->getOutput()); 116 | } 117 | 118 | /** 119 | * Evaluates the given modules and returns the raw output. This method 120 | * is useful when you want to evaluate multiple modules at once in the 121 | * original format. For example: 122 | * 123 | * ```php 124 | * $result = Pkl::rawEval('module1', 'module2'); 125 | * ``` 126 | * 127 | * The `$result` will contain the raw output of the `pkl eval module1 module2`. 128 | */ 129 | public static function rawEval(string ...$modules): string 130 | { 131 | self::initExecutable(); 132 | 133 | $process = new Process([self::$executable, 'eval', ...$modules]); 134 | $process->run(); 135 | 136 | if (!$process->isSuccessful()) { 137 | throw new \RuntimeException($process->getErrorOutput()); 138 | } 139 | 140 | return trim($process->getOutput()); 141 | } 142 | 143 | /** 144 | * Dumps all the .pkl files in the project and returns the cache file. 145 | * The cache file is used to avoid calling the PKL CLI tool on every 146 | * `Pkl::eval()` call. 147 | * 148 | * @return int the number of warmed up files 149 | */ 150 | public static function warmup(string $cacheFile): int 151 | { 152 | self::initExecutable(); 153 | 154 | $finder = new Finder(); 155 | $finder->files() 156 | ->in((string) getcwd()) 157 | ->name('*.pkl') 158 | ->sortByName(); 159 | 160 | $filenames = array_map(fn ($file) => $file->getPathname(), iterator_to_array($finder)); 161 | $process = new Process([self::$executable, 'eval', '-f', 'json', ...$filenames]); 162 | 163 | $output = trim($process->mustRun()->getOutput()); 164 | 165 | $dumpedContent = explode("\n---\n", $output); 166 | $dumpedContent = array_combine($filenames, $dumpedContent); 167 | 168 | self::$cache = new PersistentCache($cacheFile); 169 | foreach ($dumpedContent as $filename => $content) { 170 | self::$cache->set($filename, new Entry(trim($content), \md5($content), \time())); 171 | } 172 | 173 | self::$cache->save(); 174 | 175 | return \count($dumpedContent); 176 | } 177 | 178 | /** 179 | * Sets the cache to use. By default, the `PersistentCache` is used. 180 | */ 181 | public static function setCache(CacheInterface $cache): void 182 | { 183 | self::$cache = $cache; 184 | } 185 | 186 | /** 187 | * Whether the cache is enabled or not. 188 | * If the cache is disabled, the PKL CLI tool will be called on every 189 | * `Pkl::eval()` call. 190 | */ 191 | public static function toggleCache(bool $enabled): void 192 | { 193 | self::$cacheEnabled = $enabled; 194 | } 195 | 196 | private static function initExecutable(): void 197 | { 198 | self::$executable ??= (function () { 199 | $exec = $_ENV['PKL_CLI_BIN'] ?? $_SERVER['PKL_CLI_BIN'] ?? 'vendor/bin/pkl'; 200 | 201 | if (!is_executable($exec)) { 202 | throw new \RuntimeException('Pkl CLI is not installed. Make sure to set the PKL_CLI_BIN environment variable or run the `phikl install` command.'); 203 | } 204 | 205 | return $exec; 206 | })(); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/PklModule.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Phikl; 11 | 12 | use Phikl\Attribute\PklProperty; 13 | 14 | /** 15 | * @implements \ArrayAccess 16 | */ 17 | class PklModule implements \ArrayAccess, PklModuleInterface 18 | { 19 | /** 20 | * @var array 21 | */ 22 | private array $properties = []; 23 | 24 | public function __set(string $name, mixed $value): void 25 | { 26 | if (\is_array($value)) { 27 | $newValue = new self(); 28 | foreach ($value as $key => $val) { 29 | $newValue->__set($key, $val); 30 | } 31 | 32 | $value = $newValue; 33 | } 34 | 35 | $this->properties[$name] = $value; 36 | } 37 | 38 | /** 39 | * @return scalar|PklModule 40 | */ 41 | public function get(string $name): mixed 42 | { 43 | return $this->properties[$name]; 44 | } 45 | 46 | /** 47 | * @template T of object 48 | * 49 | * @param class-string $toClass 50 | * 51 | * @return T 52 | */ 53 | public function cast(string $toClass): object 54 | { 55 | $reflectionClass = new \ReflectionClass($toClass); 56 | $copy = $reflectionClass->newInstanceWithoutConstructor(); 57 | 58 | foreach ($reflectionClass->getProperties() as $destProperty) { 59 | $attribute = $destProperty->getAttributes(PklProperty::class); 60 | $sourcePropertyName = isset($attribute[0]) ? $attribute[0]->newInstance()->name : $destProperty->name; 61 | 62 | if (isset($this->properties[$sourcePropertyName])) { 63 | $srcProperty = $this->properties[$sourcePropertyName]; 64 | if ($srcProperty instanceof self) { 65 | // it should be an object or an array in the destination class 66 | $type = $destProperty->getType(); 67 | \assert($type instanceof \ReflectionNamedType); 68 | 69 | /** @var class-string $destPropertyType */ 70 | $destPropertyType = $type->getName(); 71 | 72 | if ($destPropertyType === 'array') { 73 | $destProperty->setValue($copy, $srcProperty->toArray()); 74 | } else { 75 | $destPropertyInstance = $srcProperty->cast($destPropertyType); 76 | $destProperty->setValue($copy, $destPropertyInstance); 77 | } 78 | } else { 79 | $destProperty->setValue($copy, $this->properties[$sourcePropertyName]); 80 | } 81 | } 82 | } 83 | 84 | return $copy; 85 | } 86 | 87 | public function offsetExists(mixed $offset): bool 88 | { 89 | return isset($this->properties[$offset]); 90 | } 91 | 92 | public function offsetGet(mixed $offset): mixed 93 | { 94 | return $this->properties[$offset]; 95 | } 96 | 97 | public function offsetSet(mixed $offset, mixed $value): void 98 | { 99 | $this->properties[$offset] = $value; 100 | } 101 | 102 | public function offsetUnset(mixed $offset): void 103 | { 104 | unset($this->properties[$offset]); 105 | } 106 | 107 | /** 108 | * @return array 109 | */ 110 | public function toArray(): array 111 | { 112 | $array = []; 113 | foreach ($this->properties as $key => $value) { 114 | if ($value instanceof self) { 115 | $array[$key] = $value->toArray(); 116 | 117 | continue; 118 | } 119 | 120 | $array[$key] = $value; 121 | } 122 | 123 | return $array; 124 | } 125 | 126 | /** 127 | * @return array 128 | */ 129 | public function keys(): array 130 | { 131 | return array_keys($this->properties); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/PklModuleInterface.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Phikl; 11 | 12 | interface PklModuleInterface 13 | { 14 | } 15 | -------------------------------------------------------------------------------- /tests/Cache/Adapter/ApcuCacheTest.php: -------------------------------------------------------------------------------- 1 | expectException(\InvalidArgumentException::class); 25 | $this->expectExceptionMessage('Default value must be null or an instance of Entry'); 26 | 27 | $cache->get('key', 'invalid'); 28 | } 29 | 30 | public function testGetReturnsDefaultIfKeyDoesNotExist(): void 31 | { 32 | $cache = new ApcuCacheAdapter(); 33 | 34 | $entry = new Entry('content', 'hash', 0); 35 | 36 | $this->assertNull($cache->get('nonexistent')); 37 | $this->assertSame($entry, $cache->get('nonexistent', $entry)); 38 | $this->assertFalse($cache->has('nonexistent')); 39 | } 40 | 41 | public function testGetOnValidSetEntry(): void 42 | { 43 | $cache = new ApcuCacheAdapter(); 44 | 45 | $entry = new Entry('content', 'hash', $time = \time()); 46 | 47 | $this->assertTrue($cache->set('key', $entry)); 48 | 49 | $entry = $cache->get('key'); 50 | $this->assertInstanceOf(Entry::class, $entry); 51 | $this->assertSame('content', $entry->content); 52 | $this->assertSame('hash', $entry->hash); 53 | $this->assertSame($time, $entry->timestamp); 54 | } 55 | 56 | public function testSetReturnsFalseOnInvalidEntry(): void 57 | { 58 | $cache = new ApcuCacheAdapter(); 59 | 60 | $this->assertFalse($cache->set('key', 'invalid')); 61 | } 62 | 63 | public function testDeleteEntry(): void 64 | { 65 | $cache = new ApcuCacheAdapter(); 66 | 67 | $entry = new Entry('content', 'hash', 0); 68 | $cache->set('key', $entry); 69 | 70 | $this->assertTrue($cache->delete('key')); 71 | $this->assertNull($cache->get('key')); 72 | } 73 | 74 | public function testClear(): void 75 | { 76 | $cache = new ApcuCacheAdapter(); 77 | 78 | $entry = new Entry('content', 'hash', 0); 79 | $cache->set('key', $entry); 80 | 81 | $this->assertTrue($cache->clear()); 82 | $this->assertNull($cache->get('key')); 83 | } 84 | 85 | public function testGetSetMultiple(): void 86 | { 87 | $cache = new ApcuCacheAdapter(); 88 | 89 | $entry1 = new Entry('content1', 'hash1', 0); 90 | $entry2 = new Entry('content2', 'hash2', 0); 91 | $entry3 = new Entry('content3', 'hash3', 0); 92 | 93 | $cache->setMultiple([ 94 | 'key1' => $entry1, 95 | 'key2' => $entry2, 96 | 'key3' => $entry3, 97 | ]); 98 | 99 | $entries = $cache->getMultiple(['key1', 'key2', 'key3']); 100 | 101 | $this->assertArrayHasKey('key1', $entries); 102 | $this->assertArrayHasKey('key2', $entries); 103 | $this->assertArrayHasKey('key3', $entries); 104 | 105 | $this->assertInstanceOf(Entry::class, $entries['key1']); 106 | $this->assertSame('content1', $entries['key1']->content); 107 | $this->assertSame('hash1', $entries['key1']->hash); 108 | 109 | $this->assertInstanceOf(Entry::class, $entries['key2']); 110 | $this->assertSame('content2', $entries['key2']->content); 111 | $this->assertSame('hash2', $entries['key2']->hash); 112 | 113 | $this->assertInstanceOf(Entry::class, $entries['key3']); 114 | $this->assertSame('content3', $entries['key3']->content); 115 | $this->assertSame('hash3', $entries['key3']->hash); 116 | } 117 | 118 | public function testDeleteMultiple(): void 119 | { 120 | $cache = new ApcuCacheAdapter(); 121 | 122 | $entry1 = new Entry('content1', 'hash1', 0); 123 | $entry2 = new Entry('content2', 'hash2', 0); 124 | $entry3 = new Entry('content3', 'hash3', 0); 125 | 126 | $cache->setMultiple([ 127 | 'key1' => $entry1, 128 | 'key2' => $entry2, 129 | 'key3' => $entry3, 130 | ]); 131 | 132 | $this->assertTrue($cache->deleteMultiple(['key1', 'key2'])); 133 | $this->assertNull($cache->get('key1')); 134 | $this->assertNull($cache->get('key2')); 135 | $this->assertNotNull($cache->get('key3')); 136 | } 137 | 138 | public function testHas(): void 139 | { 140 | $cache = new ApcuCacheAdapter(); 141 | 142 | $entry = new Entry('content', 'hash', 0); 143 | $cache->set('key', $entry); 144 | 145 | $this->assertTrue($cache->has('key')); 146 | $this->assertFalse($cache->has('invalid')); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /tests/Cache/Adapter/MemcachedCacheTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('Memcached is not running'); 20 | } 21 | } 22 | 23 | private function createMemcachedCache(): MemcachedCacheAdapter 24 | { 25 | return new MemcachedCacheAdapter(new MemcachedServer('localhost', 11211)); 26 | } 27 | 28 | public function testGetWithDefaultOtherThanEntryInstance(): void 29 | { 30 | $cache = $this->createMemcachedCache(); 31 | 32 | $this->expectException(\InvalidArgumentException::class); 33 | $this->expectExceptionMessage('Default value must be null or an instance of Entry'); 34 | 35 | $cache->get('key', 'invalid'); 36 | } 37 | 38 | public function testGetReturnsDefaultIfKeyDoesNotExist(): void 39 | { 40 | $cache = $this->createMemcachedCache(); 41 | 42 | $entry = new Entry('content', 'hash', 0); 43 | 44 | $this->assertNull($cache->get('nonexistent')); 45 | $this->assertSame($entry, $cache->get('nonexistent', $entry)); 46 | $this->assertFalse($cache->has('nonexistent')); 47 | } 48 | 49 | public function testGetOnValidSetEntry(): void 50 | { 51 | $cache = $this->createMemcachedCache(); 52 | 53 | $entry = new Entry('content', 'hash', $time = \time()); 54 | 55 | $this->assertTrue($cache->set('key', $entry)); 56 | 57 | $entry = $cache->get('key'); 58 | $this->assertInstanceOf(Entry::class, $entry); 59 | $this->assertSame('content', $entry->content); 60 | $this->assertSame('hash', $entry->hash); 61 | $this->assertSame($time, $entry->timestamp); 62 | } 63 | 64 | public function testSetReturnsFalseOnInvalidEntry(): void 65 | { 66 | $cache = $this->createMemcachedCache(); 67 | 68 | $this->assertFalse($cache->set('key', 'invalid')); 69 | } 70 | 71 | public function testDeleteEntry(): void 72 | { 73 | $cache = $this->createMemcachedCache(); 74 | 75 | $entry = new Entry('content', 'hash', 0); 76 | $cache->set('key', $entry); 77 | 78 | $this->assertTrue($cache->delete('key')); 79 | $this->assertNull($cache->get('key')); 80 | } 81 | 82 | public function testClear(): void 83 | { 84 | $cache = $this->createMemcachedCache(); 85 | 86 | $entry = new Entry('content', 'hash', 0); 87 | $cache->set('key', $entry); 88 | 89 | $this->assertTrue($cache->clear()); 90 | $this->assertNull($cache->get('key')); 91 | } 92 | 93 | public function testGetSetMultiple(): void 94 | { 95 | $cache = $this->createMemcachedCache(); 96 | 97 | $entry1 = new Entry('content1', 'hash1', 0); 98 | $entry2 = new Entry('content2', 'hash2', 0); 99 | $entry3 = new Entry('content3', 'hash3', 0); 100 | 101 | $cache->setMultiple([ 102 | 'key1' => $entry1, 103 | 'key2' => $entry2, 104 | 'key3' => $entry3, 105 | ]); 106 | 107 | $entries = $cache->getMultiple(['key1', 'key2', 'key3']); 108 | 109 | $this->assertArrayHasKey('key1', $entries); 110 | $this->assertArrayHasKey('key2', $entries); 111 | $this->assertArrayHasKey('key3', $entries); 112 | 113 | $this->assertInstanceOf(Entry::class, $entries['key1']); 114 | $this->assertSame('content1', $entries['key1']->content); 115 | $this->assertSame('hash1', $entries['key1']->hash); 116 | 117 | $this->assertInstanceOf(Entry::class, $entries['key2']); 118 | $this->assertSame('content2', $entries['key2']->content); 119 | $this->assertSame('hash2', $entries['key2']->hash); 120 | 121 | $this->assertInstanceOf(Entry::class, $entries['key3']); 122 | $this->assertSame('content3', $entries['key3']->content); 123 | $this->assertSame('hash3', $entries['key3']->hash); 124 | } 125 | 126 | public function testDeleteMultiple(): void 127 | { 128 | $cache = $this->createMemcachedCache(); 129 | 130 | $entry1 = new Entry('content1', 'hash1', 0); 131 | $entry2 = new Entry('content2', 'hash2', 0); 132 | $entry3 = new Entry('content3', 'hash3', 0); 133 | 134 | $cache->setMultiple([ 135 | 'key1' => $entry1, 136 | 'key2' => $entry2, 137 | 'key3' => $entry3, 138 | ]); 139 | 140 | $this->assertTrue($cache->deleteMultiple(['key1', 'key2'])); 141 | $this->assertNull($cache->get('key1')); 142 | $this->assertNull($cache->get('key2')); 143 | $this->assertNotNull($cache->get('key3')); 144 | } 145 | 146 | public function testHas(): void 147 | { 148 | $cache = $this->createMemcachedCache(); 149 | 150 | $entry = new Entry('content', 'hash', 0); 151 | $cache->set('key', $entry); 152 | 153 | $this->assertTrue($cache->has('key')); 154 | $this->assertFalse($cache->has('invalid')); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /tests/Cache/PersistentCacheTest.php: -------------------------------------------------------------------------------- 1 | set('key', new Entry('content', 'hash', 0)); 23 | 24 | $this->assertNotNull($cache->get('key')); 25 | $this->assertSame('content', $cache->get('key')->content); 26 | } 27 | 28 | public function testGetWithDefault(): void 29 | { 30 | $cache = new PersistentCache(); 31 | 32 | $default = new Entry('default', 'hash', 0); 33 | $this->assertSame($default, $cache->get('key', $default)); 34 | } 35 | 36 | public function testGetWithInvalidDefaultThrows(): void 37 | { 38 | $cache = new PersistentCache(); 39 | 40 | $this->expectException(\InvalidArgumentException::class); 41 | $this->expectExceptionMessage('Default value must be an instance of Entry'); 42 | 43 | $cache->get('key', 'invalid'); 44 | } 45 | 46 | public function testGetIsHitButEntryIsCorrupted(): void 47 | { 48 | $cache = new PersistentCache(); 49 | 50 | touch('corrupted.pkl', $time = \time()); 51 | 52 | $entry = new Entry('content', \md5('content'), $time); 53 | $cache->set('corrupted.pkl', $entry); 54 | $this->assertSame($entry, $cache->get('corrupted.pkl')); 55 | 56 | $entry->hash = 'invalid'; 57 | 58 | // not the same because entry was generated again 59 | $this->assertNotSame($entry, $cache->get('corrupted.pkl')); 60 | $this->assertSame('content', $cache->get('corrupted.pkl')->content); 61 | $this->assertSame(\md5('content'), $cache->get('corrupted.pkl')->hash); 62 | 63 | unlink('corrupted.pkl'); 64 | } 65 | 66 | public function testGetIsHitButEntryIsStalled(): void 67 | { 68 | $cache = new PersistentCache(); 69 | 70 | touch('stalled.pkl', $time = \time()); 71 | 72 | $entry = new Entry('content', \md5('content'), $time); 73 | 74 | $cache->set('stalled.pkl', $entry); 75 | $this->assertSame($entry, $cache->get('stalled.pkl')); 76 | 77 | // simulate a file touch 78 | $entry->timestamp -= 10; 79 | 80 | // not the same because entry was generated again 81 | $this->assertNotSame($entry, $cache->get('stalled.pkl')); 82 | $this->assertSame('content', $cache->get('stalled.pkl')->content); 83 | $this->assertSame(\md5('content'), $cache->get('stalled.pkl')->hash); 84 | 85 | unlink('stalled.pkl'); 86 | } 87 | 88 | public function testSet(): void 89 | { 90 | $cache = new PersistentCache(); 91 | $this->assertNull($cache->get('key')); 92 | 93 | $this->assertTrue($cache->set('key', new Entry('content', 'hash', 0))); 94 | $this->assertNotNull($cache->get('key')); 95 | } 96 | 97 | public function testSetWithInvalidObjectReturnsFalse(): void 98 | { 99 | $cache = new PersistentCache(); 100 | $this->assertFalse($cache->set('key', 'invalid')); 101 | } 102 | 103 | public function testDelete(): void 104 | { 105 | $cache = new PersistentCache(); 106 | $cache->set('key', new Entry('content', 'hash', 0)); 107 | $cache->set('key2', new Entry('content', 'hash', 0)); 108 | 109 | $this->assertNotNull($cache->get('key')); 110 | $this->assertNotNull($cache->get('key2')); 111 | 112 | $this->assertTrue($cache->delete('key')); 113 | $this->assertNull($cache->get('key')); 114 | $this->assertNotNull($cache->get('key2')); 115 | } 116 | 117 | public function testGetMultiple(): void 118 | { 119 | $cache = new PersistentCache(); 120 | $cache->set('key', new Entry('content', 'hash', 0)); 121 | $cache->set('key2', new Entry('content', 'hash', 0)); 122 | 123 | $entries = $cache->getMultiple(['key', 'key2']); 124 | $this->assertCount(2, $entries); 125 | 126 | $this->assertArrayHasKey('key', $entries); 127 | $this->assertArrayHasKey('key2', $entries); 128 | 129 | $this->assertNotNull($entries['key']); 130 | $this->assertNotNull($entries['key2']); 131 | } 132 | 133 | public function testSetMultiple(): void 134 | { 135 | $cache = new PersistentCache(); 136 | $this->assertNull($cache->get('key')); 137 | $this->assertNull($cache->get('key2')); 138 | 139 | $entries = [ 140 | 'key' => new Entry('content', 'hash', 0), 141 | 'key2' => new Entry('content', 'hash', 0), 142 | ]; 143 | 144 | $this->assertTrue($cache->setMultiple($entries)); 145 | $this->assertNotNull($cache->get('key')); 146 | $this->assertNotNull($cache->get('key2')); 147 | } 148 | 149 | public function testDeleteMultiple(): void 150 | { 151 | $cache = new PersistentCache(); 152 | $cache->set('key', new Entry('content', 'hash', 0)); 153 | $cache->set('key2', new Entry('content', 'hash', 0)); 154 | 155 | $this->assertNotNull($cache->get('key')); 156 | $this->assertNotNull($cache->get('key2')); 157 | 158 | $this->assertTrue($cache->deleteMultiple(['key', 'key2'])); 159 | $this->assertNull($cache->get('key')); 160 | $this->assertNull($cache->get('key2')); 161 | } 162 | 163 | public function testHas(): void 164 | { 165 | $cache = new PersistentCache(); 166 | $cache->set('key', new Entry('content', 'hash', 0)); 167 | 168 | $this->assertTrue($cache->has('key')); 169 | $this->assertFalse($cache->has('invalid')); 170 | } 171 | 172 | public function testClear(): void 173 | { 174 | $cache = new PersistentCache(); 175 | $cache->set('key', new Entry('content', 'hash', 0)); 176 | $this->assertNotNull($cache->get('key')); 177 | 178 | $cache->clear(); 179 | $this->assertNull($cache->get('key')); 180 | } 181 | 182 | public function testSave(): void 183 | { 184 | $cache = new PersistentCache(); 185 | $cache->set('key', new Entry('content', 'hash', 0)); 186 | $cache->save(); 187 | 188 | $this->assertFileExists($cache->getCacheFile()); 189 | $cache->clear(); 190 | 191 | $cache->load(); 192 | $this->assertNotNull($cache->get('key')); 193 | 194 | unlink($cache->getCacheFile()); 195 | } 196 | 197 | public function testGetCacheFile(): void 198 | { 199 | $cache = new PersistentCache(); 200 | $this->assertSame('.phikl.cache', $cache->getCacheFile()); 201 | } 202 | 203 | public function testLoadWithCorruptedCacheFile(): void 204 | { 205 | $cache = new PersistentCache(); 206 | $cache->set('key', new Entry('content', 'hash', 0)); 207 | $cache->save(); 208 | 209 | $cacheFile = $cache->getCacheFile(); 210 | file_put_contents($cacheFile, 'invalid'); 211 | 212 | $cache->load(); 213 | $this->assertNull($cache->get('key')); 214 | $this->assertFileDoesNotExist($cacheFile); 215 | } 216 | 217 | public function testSetCacheFile(): void 218 | { 219 | $cache = new PersistentCache(); 220 | $cache->setCacheFile('test.cache'); 221 | 222 | $this->assertSame('test.cache', $cache->getCacheFile()); 223 | } 224 | 225 | public function testGuessCacheFileWithEnv(): void 226 | { 227 | $_ENV['PHIKL_CACHE_FILE'] = 'env.cache'; 228 | 229 | $cache = new PersistentCache(); 230 | $this->assertSame('env.cache', $cache->getCacheFile()); 231 | 232 | unset($_ENV['PHIKL_CACHE_FILE']); 233 | } 234 | 235 | public function testGuessCacheFileWithServer(): void 236 | { 237 | $_SERVER['PHIKL_CACHE_FILE'] = 'server.cache'; 238 | 239 | $cache = new PersistentCache(); 240 | $this->assertSame('server.cache', $cache->getCacheFile()); 241 | 242 | unset($_SERVER['PHIKL_CACHE_FILE']); 243 | } 244 | 245 | public function testSetCacheFileIsPrioritizedOverEnv(): void 246 | { 247 | $_ENV['PHIKL_CACHE_FILE'] = 'env.cache'; 248 | 249 | $cache = new PersistentCache(); 250 | $cache->setCacheFile('test.cache'); 251 | 252 | $this->assertSame('test.cache', $cache->getCacheFile()); 253 | 254 | unset($_ENV['PHIKL_CACHE_FILE']); 255 | } 256 | 257 | public function testGuessCacheFileFallbacksOnDefaultPath(): void 258 | { 259 | $cache = new PersistentCache(); 260 | $this->assertSame('.phikl.cache', $cache->getCacheFile()); 261 | } 262 | 263 | public function testValidateOnValidCache(): void 264 | { 265 | $cache = new PersistentCache(); 266 | $cache->set('key', new Entry('content', \md5('content'), 0)); 267 | $cache->save(); 268 | 269 | $cache->validate(); 270 | 271 | unlink($cache->getCacheFile()); 272 | 273 | $this->expectNotToPerformAssertions(); 274 | } 275 | 276 | public function testValidateOnInvalidCache(): void 277 | { 278 | $cache = new PersistentCache(); 279 | $cache->set('key', new Entry('content', 'invalid', 0)); 280 | $cache->save(); 281 | 282 | $this->expectException(CorruptedCacheException::class); 283 | $this->expectExceptionMessage('The cache file ".phikl.cache" seems corrupted and should be generated again with the `phikl warmup` command.'); 284 | 285 | $cache->validate(); 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /tests/Fixtures/Address.php: -------------------------------------------------------------------------------- 1 | */ 11 | public array $address; 12 | } 13 | -------------------------------------------------------------------------------- /tests/Fixtures/UserWithAttributes.php: -------------------------------------------------------------------------------- 1 | mustRun(); 22 | 23 | $this->assertMatchesRegularExpression('/(.*)Running Pkl \d+.\d+.\d+ \(.+\)/', $process->getOutput()); 24 | } 25 | 26 | public function testInstallAlreadyPresent(): void 27 | { 28 | $process = new Process(['php', __DIR__.'/../phikl', 'install']); 29 | $process->mustRun(); 30 | 31 | $this->assertMatchesRegularExpression('/(.*)Pkl CLI is already installed in (.+)/', $process->getOutput()); 32 | } 33 | 34 | public function testCanEval(): void 35 | { 36 | $process = new Process(['php', __DIR__.'/../phikl', 'eval', 'tests/Fixtures/user.pkl']); 37 | $process->mustRun(); 38 | 39 | $this->assertSame(<<getOutput())); 51 | } 52 | 53 | public function testCanDump(): void 54 | { 55 | $process = new Process(['php', __DIR__.'/../phikl', 'warmup', 'tests/Fixtures/simple.pkl']); 56 | $process->mustRun(); 57 | 58 | $this->assertFileExists('.phikl.cache'); 59 | unlink('.phikl.cache'); 60 | } 61 | 62 | public function testCanValidateCache(): void 63 | { 64 | $process = new Process(['php', __DIR__.'/../phikl', 'warmup', 'tests/Fixtures/simple.pkl']); 65 | $process->mustRun(); 66 | 67 | $process = new Process(['php', __DIR__.'/../phikl', 'validate-cache']); 68 | $process->mustRun(); 69 | 70 | $this->assertMatchesRegularExpression('/(.*)\[OK] Cache file "(.+)" is valid/', $process->getOutput()); 71 | 72 | unlink('.phikl.cache'); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/PklModuleTest.php: -------------------------------------------------------------------------------- 1 | cast(ClassWithAttributes::class); 28 | 29 | $this->assertSame('Alex', $class->name); 30 | $this->assertSame('Daubois', $class->surname); 31 | } 32 | 33 | public function testCastNested(): void 34 | { 35 | /** @var PklModule $module */ 36 | $module = Pkl::eval(__DIR__.'/Fixtures/user.pkl'); 37 | $class = $module->get('user'); 38 | 39 | $this->assertInstanceOf(PklModule::class, $class); 40 | $class = $class->cast(User::class); 41 | 42 | $this->assertSame(1, $class->id); 43 | $this->assertSame('John Doe', $class->name); 44 | 45 | $this->assertInstanceOf(Address::class, $class->address); 46 | $this->assertSame('62701', $class->address->zip); 47 | $this->assertSame('123 Main St', $class->address->street); 48 | $this->assertSame('IL', $class->address->state); 49 | $this->assertSame('Springfield', $class->address->city); 50 | } 51 | 52 | public function testCastNestedArray(): void 53 | { 54 | /** @var PklModule $module */ 55 | $module = Pkl::eval(__DIR__.'/Fixtures/user.pkl'); 56 | $class = $module->get('user'); 57 | 58 | $this->assertInstanceOf(PklModule::class, $class); 59 | $class = $class->cast(UserWithArrayAddress::class); 60 | 61 | $this->assertSame(1, $class->id); 62 | $this->assertSame('John Doe', $class->name); 63 | 64 | $this->assertIsArray($class->address); 65 | $this->assertSame('62701', $class->address['zip']); 66 | $this->assertSame('123 Main St', $class->address['street']); 67 | $this->assertSame('IL', $class->address['state']); 68 | $this->assertSame('Springfield', $class->address['city']); 69 | } 70 | 71 | public function testCastNestedWithAttributes(): void 72 | { 73 | /** @var PklModule $module */ 74 | $module = Pkl::eval(__DIR__.'/Fixtures/user.pkl'); 75 | 76 | $class = $module->get('user'); 77 | 78 | $this->assertInstanceOf(PklModule::class, $class); 79 | $class = $class->cast(UserWithAttributes::class); 80 | 81 | $this->assertSame(1, $class->identifier); 82 | $this->assertSame('John Doe', $class->nameOfUser); 83 | 84 | $this->assertInstanceOf(Address::class, $class->addressOfUser); 85 | $this->assertSame('62701', $class->addressOfUser->zip); 86 | $this->assertSame('123 Main St', $class->addressOfUser->street); 87 | $this->assertSame('IL', $class->addressOfUser->state); 88 | $this->assertSame('Springfield', $class->addressOfUser->city); 89 | } 90 | 91 | public function testAmends(): void 92 | { 93 | /** @var PklModule $module */ 94 | $module = Pkl::eval(__DIR__.'/Fixtures/amends.pkl'); 95 | 96 | $this->assertInstanceOf(PklModule::class, $module->get('bird')); 97 | $this->assertInstanceOf(PklModule::class, $module->get('parrot')); 98 | 99 | $this->assertInstanceOf(PklModule::class, $module->get('bird')->get('taxonomy')); 100 | $this->assertInstanceOf(PklModule::class, $module->get('parrot')->get('taxonomy')); 101 | 102 | $this->assertSame('Animalia', $module->get('bird')->get('taxonomy')->get('kingdom')); 103 | $this->assertSame('Animalia', $module->get('parrot')->get('taxonomy')->get('kingdom')); 104 | 105 | $this->assertSame('Dinosauria', $module->get('bird')->get('taxonomy')->get('clade')); 106 | $this->assertSame('Dinosauria', $module->get('parrot')->get('taxonomy')->get('clade')); 107 | 108 | // amended 109 | $this->assertSame('Columbiformes', $module->get('bird')->get('taxonomy')->get('order')); 110 | $this->assertSame('Psittaciformes', $module->get('parrot')->get('taxonomy')->get('order')); 111 | 112 | // amended 113 | $this->assertSame('Seeds', $module->get('bird')->get('diet')); 114 | $this->assertSame('Berries', $module->get('parrot')->get('diet')); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /tests/PklTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(PklModule::class, $result); 38 | $this->assertSame('Pkl: Configure your Systems in New Ways', $result->get('name')); 39 | $this->assertSame(100, $result->get('attendants')); 40 | $this->assertTrue($result->get('isInteractive')); 41 | $this->assertSame(13.37, $result->get('amountLearned')); 42 | } 43 | 44 | public function testEvalCachesOnCacheMiss(): void 45 | { 46 | $cache = new PersistentCache(); 47 | Pkl::setCache($cache); 48 | 49 | $this->assertFalse($cache->has(__DIR__.'/Fixtures/simple.pkl')); 50 | Pkl::eval(__DIR__.'/Fixtures/simple.pkl'); 51 | 52 | $cache->clear(); 53 | $cache->load(); 54 | 55 | $this->assertTrue($cache->has(__DIR__.'/Fixtures/simple.pkl')); 56 | 57 | unlink($cache->getCacheFile()); 58 | } 59 | 60 | public function testEvalDoesntCacheOnCacheMissWithDisabledCache(): void 61 | { 62 | $cache = new PersistentCache(); 63 | Pkl::setCache($cache); 64 | Pkl::toggleCache(false); 65 | 66 | $this->assertFalse($cache->has(__DIR__.'/Fixtures/simple.pkl')); 67 | Pkl::eval(__DIR__.'/Fixtures/simple.pkl'); 68 | $this->assertFalse($cache->has(__DIR__.'/Fixtures/simple.pkl')); 69 | } 70 | 71 | public function testEvalMultipleConfigFiles(): void 72 | { 73 | $result = Pkl::eval(__DIR__.'/Fixtures/multiple.pkl'); 74 | 75 | $this->assertInstanceOf(PklModule::class, $result); 76 | 77 | $this->assertInstanceOf(PklModule::class, $result->get('woodPigeon')); 78 | $this->assertSame('Common wood pigeon', $result->get('woodPigeon')->get('name')); 79 | $this->assertSame('Seeds', $result->get('woodPigeon')->get('diet')); 80 | 81 | $this->assertInstanceOf(PklModule::class, $result->get('woodPigeon')->get('taxonomy')); 82 | $this->assertSame('Columba palumbus', $result->get('woodPigeon')->get('taxonomy')->get('species')); 83 | 84 | $this->assertInstanceOf(PklModule::class, $result->get('stockPigeon')); 85 | $this->assertInstanceOf(PklModule::class, $result->get('dodo')); 86 | } 87 | 88 | public function testEvalWithCustomClass(): void 89 | { 90 | $result = Pkl::eval(__DIR__.'/Fixtures/user.pkl', User::class); 91 | 92 | $this->assertIsArray($result); 93 | 94 | $user = $result['user']; 95 | $this->assertInstanceOf(User::class, $user); 96 | $this->assertSame(1, $user->id); 97 | $this->assertSame('John Doe', $user->name); 98 | $this->assertSame('123 Main St', $user->address->street); 99 | $this->assertSame('Springfield', $user->address->city); 100 | $this->assertSame('IL', $user->address->state); 101 | $this->assertSame('62701', $user->address->zip); 102 | } 103 | 104 | /** 105 | * Because PKL CLI tool is used only on a cache miss, we check we 106 | * go through the PKL CLI tool when the cache is empty. By unsetting 107 | * the PKL_CLI_BIN environment variable, we make sure the PKL CLI 108 | * tool is not found and an exception is thrown. 109 | * 110 | * @runInSeparateProcess 111 | */ 112 | public function testCacheMissWithInvalidExecutable(): void 113 | { 114 | $_ENV['PKL_CLI_BIN'] = 'invalidbin'; 115 | 116 | $this->expectException(\RuntimeException::class); 117 | $this->expectExceptionMessage('Pkl CLI is not installed. Make sure to set the PKL_CLI_BIN environment variable or run the `phikl install` command.'); 118 | 119 | // ensure it is a cache miss 120 | Pkl::setCache(new PersistentCache()); 121 | Pkl::eval(__DIR__.'/Fixtures/simple.pkl'); 122 | } 123 | 124 | /** 125 | * @runInSeparateProcess 126 | */ 127 | public function testCacheHitWithInvalidExecutable(): void 128 | { 129 | unset($_ENV['PKL_CLI_BIN']); 130 | 131 | $cache = new PersistentCache(); 132 | $cache->set('foo.pkl', new Entry(<<save(); 138 | 139 | // pkl cli tool must not be called 140 | $result = Pkl::eval('foo.pkl'); 141 | $this->assertInstanceOf(PklModule::class, $result); 142 | $this->assertSame('bar', $result->get('name')); 143 | 144 | unlink($cache->getCacheFile()); 145 | } 146 | } 147 | --------------------------------------------------------------------------------