├── .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 | [](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