diff --git a/.github/workflows/test-suite.yaml b/.github/workflows/test-suite.yaml index a3abc65..ca77cc5 100644 --- a/.github/workflows/test-suite.yaml +++ b/.github/workflows/test-suite.yaml @@ -28,7 +28,7 @@ jobs: run: composer update - name: Run Static Analysis - run: vendor/bin/psalm + run: vendor/bin/phpstan tests: name: Tests diff --git a/Makefile b/Makefile index 7f91e4f..baeb3c2 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ prod production: # Build application for production test: # Run coding standards/static analysis checks and tests @vendor/bin/php-cs-fixer fix --diff --dry-run \ - && vendor/bin/psalm \ + && vendor/bin/phpstan \ && vendor/bin/phpunit --coverage-text coverage: # Generate an HTML coverage report diff --git a/composer.json b/composer.json index 394be37..67492ba 100644 --- a/composer.json +++ b/composer.json @@ -19,14 +19,14 @@ } ], "require": { - "php": ">=8.1", - "symfony/yaml": "^6.0", + "php": "^8.0 || ^8.1 || ^8.2", + "symfony/yaml": "^5.0 || ^6.0", "yosymfony/toml": "^1.0" }, "require-dev": { - "phlak/coding-standards": "^2.0", + "phlak/coding-standards": "^2.2", + "phpstan/phpstan": "^1.10", "psy/psysh": "^0.11", - "vimeo/psalm": "^5.15", "yoast/phpunit-polyfills": "^2.0" }, "autoload": { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..c908ca4 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,6 @@ +parameters: + ignoreErrors: + - + message: "#^Method PHLAK\\\\Config\\\\Config\\:\\:load\\(\\) throws checked exception PHLAK\\\\Config\\\\Exceptions\\\\InvalidFileException but it's missing from the PHPDoc @throws tag\\.$#" + count: 2 + path: src/Config.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..f6b0a56 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,27 @@ +parameters: + + paths: + - src + - tests + + level: max + + checkFunctionNameCase: true + checkMissingIterableValueType: false + + reportUnmatchedIgnoredErrors: false + + exceptions: + implicitThrows: false + + check: + missingCheckedExceptionInThrows: true + tooWideThrowType: true + + uncheckedExceptionClasses: + - 'InvalidArgumentException' + - 'RuntimeException' + - 'PHPUnit\Framework\Exception' + +includes: + - phpstan-baseline.neon diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 5bfb8e3..0000000 --- a/psalm.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Config.php b/src/Config.php index 0933772..44c92c0 100644 --- a/src/Config.php +++ b/src/Config.php @@ -5,12 +5,16 @@ use ArrayAccess; use DirectoryIterator; use IteratorAggregate; -use PHLAK\Config\Exceptions\InvalidContextException; use PHLAK\Config\Interfaces\ConfigInterface; +use PHLAK\Config\Loaders\Loader; use PHLAK\Config\Traits\Arrayable; use RuntimeException; use SplFileInfo; +/** + * @implements ArrayAccess + * @implements IteratorAggregate + */ class Config implements ConfigInterface, ArrayAccess, IteratorAggregate { use Arrayable; @@ -21,28 +25,17 @@ class Config implements ConfigInterface, ArrayAccess, IteratorAggregate /** * Create a new Config object. * - * @param mixed $context Raw array of configuration options or path to a - * configuration file or directory containing one or - * more configuration files - * @param string|array $prefix A key under which the loaded config will be nested - * @throws InvalidContextException + * @param array|string $context Raw array of configuration options or path to a configuration file + * or directory containing one or more configuration files + * @param string $prefix A key under which the loaded config will be nested */ - public function __construct($context = null, string $prefix = null) + public function __construct(array|string $context = null, string $prefix = null) { - switch (gettype($context)) { - case 'NULL': - break; - case 'array': - $this->config = $prefix ? [$prefix => $context] : $context; - - break; - case 'string': - $this->load($context, $prefix); - - break; - default: - throw new InvalidContextException('Failed to initialize config'); - } + match (gettype($context)) { + 'array' => $this->config = $prefix ? [$prefix => $context] : $context, + 'string' => $this->load($context, $prefix), + 'NULL' => null, + }; } /** @@ -54,7 +47,7 @@ public function __construct($context = null, string $prefix = null) */ public static function fromDirectory(string $path): ConfigInterface { - $config = new self(); + $config = new self; foreach (new DirectoryIterator($path) as $file) { if ($file->isFile()) { @@ -93,7 +86,7 @@ public function set(string $key, mixed $value): bool * Retrieve a configuration option via a provided key. * * @param string $key Unique configuration option key - * @param mixed|null $default Default value to return if option does not exist + * @param mixed $default Default value to return if option does not exist * * @return mixed Stored config item or $default value */ @@ -138,10 +131,9 @@ public function has(string $key): bool * @param string $key Unique configuration option key * @param mixed $value Config item value * - * @return true - * * @throws RuntimeException * + * @return true */ public function append(string $key, mixed $value): bool { @@ -166,10 +158,9 @@ public function append(string $key, mixed $value): bool * @param string $key Unique configuration option key * @param mixed $value Config item value * - * @return true - * * @throws RuntimeException * + * @return true */ public function prepend(string $key, mixed $value): bool { @@ -221,6 +212,7 @@ public function load(string $path, string $prefix = null, bool $override = true) $className = $file->isDir() ? 'Directory' : ucfirst(strtolower($file->getExtension())); $classPath = 'PHLAK\\Config\\Loaders\\' . $className; + /** @var Loader $loader */ $loader = new $classPath($file->getRealPath()); $newConfig = $prefix ? [$prefix => $loader->getArray()] : $loader->getArray(); @@ -259,12 +251,17 @@ public function merge(ConfigInterface $config, bool $override = true): ConfigInt * * @param string $key Unique configuration option key * - * @return \PHLAK\Config\Interfaces\ConfigInterface A new ConfigInterface object - * @throws InvalidContextException + * @return ConfigInterface A new ConfigInterface object */ public function split(string $key): ConfigInterface { - return new self($this->get($key)); + $value = $this->get($key); + + if (! is_array($value)) { + throw new RuntimeException(sprintf('Config item [%s] is not an array', $key)); + } + + return new self($value); } /** diff --git a/src/Exceptions/ConfigException.php b/src/Exceptions/ConfigException.php index d9252c8..ce606ff 100644 --- a/src/Exceptions/ConfigException.php +++ b/src/Exceptions/ConfigException.php @@ -4,6 +4,4 @@ use Exception; -abstract class ConfigException extends Exception -{ -} +abstract class ConfigException extends Exception {} diff --git a/src/Exceptions/InvalidContextException.php b/src/Exceptions/InvalidContextException.php deleted file mode 100644 index 29a8466..0000000 --- a/src/Exceptions/InvalidContextException.php +++ /dev/null @@ -1,7 +0,0 @@ -isDir() ? 'Directory' : ucfirst(strtolower($file->getExtension())); $classPath = 'PHLAK\\Config\\Loaders\\' . $className; + /** @var Loader $loader */ $loader = new $classPath($file->getPathname()); try { $contents = array_merge($contents, $loader->getArray()); - } catch (InvalidFileException $e) { - // Ignore it and continue + } catch (InvalidFileException) { + continue; } } diff --git a/src/Loaders/Json.php b/src/Loaders/Json.php index e9552be..ef8bfed 100644 --- a/src/Loaders/Json.php +++ b/src/Loaders/Json.php @@ -18,6 +18,11 @@ public function getArray(): array { $contents = file_get_contents($this->context); + if ($contents === false) { + throw new InvalidFileException('Unable to parse invalid JSON file at ' . $this->context); + } + + /** @var array|null $parsed */ $parsed = json_decode($contents, true); if (is_null($parsed)) { diff --git a/src/Loaders/Loader.php b/src/Loaders/Loader.php index 012c5a7..b62929e 100644 --- a/src/Loaders/Loader.php +++ b/src/Loaders/Loader.php @@ -2,6 +2,7 @@ namespace PHLAK\Config\Loaders; +use PHLAK\Config\Exceptions\InvalidFileException; use PHLAK\Config\Interfaces\Loadable; abstract class Loader implements Loadable @@ -22,6 +23,8 @@ public function __construct(string $context) /** * Retrieve the context as an array of configuration options. * + * @throws InvalidFileException + * * @return array Array of configuration options */ abstract public function getArray(): array; diff --git a/src/Loaders/Toml.php b/src/Loaders/Toml.php index f4be8ea..0302bd1 100644 --- a/src/Loaders/Toml.php +++ b/src/Loaders/Toml.php @@ -19,6 +19,7 @@ class Toml extends Loader public function getArray(): array { try { + /** @var array $parsed */ $parsed = TomlParser::parseFile($this->context); } catch (ParseException $e) { throw new InvalidFileException($e->getMessage()); diff --git a/src/Loaders/Xml.php b/src/Loaders/Xml.php index dc6b892..fda55c1 100644 --- a/src/Loaders/Xml.php +++ b/src/Loaders/Xml.php @@ -2,6 +2,7 @@ namespace PHLAK\Config\Loaders; +use JsonException; use PHLAK\Config\Exceptions\InvalidFileException; class Xml extends Loader @@ -18,10 +19,19 @@ public function getArray(): array { $parsed = @simplexml_load_file($this->context); - if (! $parsed) { + if ($parsed === false) { throw new InvalidFileException('Unable to parse invalid XML file at ' . $this->context); } - return json_decode(json_encode($parsed), true); + try { + $json = json_encode($parsed, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new InvalidFileException(previous: $exception); + } + + /** @var array $array */ + $array = json_decode($json, true); + + return $array; } } diff --git a/src/Loaders/Yaml.php b/src/Loaders/Yaml.php index 705fb35..b7b98fc 100644 --- a/src/Loaders/Yaml.php +++ b/src/Loaders/Yaml.php @@ -18,10 +18,16 @@ class Yaml extends Loader */ public function getArray(): array { + $contents = file_get_contents($this->context); + + if ($contents === false) { + throw new InvalidFileException(sprintf('Unable to parse file [%s]', $this->context)); + } + try { - $parsed = YamlParser::parse(file_get_contents($this->context)); - } catch (ParseException $e) { - throw new InvalidFileException($e->getMessage()); + $parsed = YamlParser::parse($contents); + } catch (ParseException $exception) { + throw new InvalidFileException($exception->getMessage()); } if (! is_array($parsed)) { diff --git a/src/Traits/Arrayable.php b/src/Traits/Arrayable.php index 150ad17..b79fff9 100644 --- a/src/Traits/Arrayable.php +++ b/src/Traits/Arrayable.php @@ -20,11 +20,9 @@ public function getIterator(): Traversable /** * Determine whether an item exists at a specific offset. * - * @param int $offset Offset to check for existence - * - * @return bool + * @param mixed $offset Offset to check for existence */ - public function offsetExists($offset): bool + public function offsetExists(mixed $offset): bool { return isset($this->config[$offset]); } @@ -32,11 +30,9 @@ public function offsetExists($offset): bool /** * Retrieve an item at a specific offset. * - * @param int $offset Position of character to get - * - * @return mixed + * @param mixed $offset Position of character to get */ - public function offsetGet($offset): mixed + public function offsetGet(mixed $offset): mixed { return $this->config[$offset]; } diff --git a/tests/ArrayTest.php b/tests/ArrayTest.php index 9385fbf..b6b06b7 100644 --- a/tests/ArrayTest.php +++ b/tests/ArrayTest.php @@ -8,7 +8,7 @@ /** @covers \PHLAK\Config\Traits\Arrayable */ class ArrayTest extends TestCase { - public function test_it_can_initialize_an_array() + public function test_it_can_initialize_an_array(): void { $config = new Config(['foo' => ['bar' => 'foobar']]); @@ -16,7 +16,7 @@ public function test_it_can_initialize_an_array() $this->assertEquals('foobar', $config->get('foo.bar')); } - public function test_it_can_initialize_an_array_with_a_prefix() + public function test_it_can_initialize_an_array_with_a_prefix(): void { $config = new Config(['foo' => ['bar' => 'foobar']], 'baz'); diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index 776654a..0c3ff66 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -3,7 +3,6 @@ namespace PHLAK\Config\Tests; use PHLAK\Config\Config; -use PHLAK\Config\Exceptions\InvalidContextException; use PHLAK\Config\Interfaces\ConfigInterface; use RuntimeException; use Yoast\PHPUnitPolyfills\TestCases\TestCase; @@ -13,71 +12,71 @@ class ConfigTest extends TestCase { public function test_it_is_instantiable(): void { - $config = new Config(); + $config = new Config; $this->assertInstanceOf(ConfigInterface::class, $config); } - public function test_it_can_set_and_retrieve_an_item() + public function test_it_can_set_and_retrieve_an_item(): void { - $config = new Config(); + $config = new Config; $this->assertTrue($config->set('name', 'John Pinkerton')); $this->assertEquals('John Pinkerton', $config->get('name')); } - public function test_it_can_set_and_retrieve_an_item_by_dot_notation() + public function test_it_can_set_and_retrieve_an_item_by_dot_notation(): void { - $config = new Config(); + $config = new Config; $this->assertTrue($config->set('foo.bar.baz', 'foo-bar-baz')); $this->assertEquals('foo-bar-baz', $config->get('foo.bar.baz')); $this->assertEquals(['baz' => 'foo-bar-baz'], $config->get('foo.bar')); } - public function test_it_returns_null_for_nonexistant_items() + public function test_it_returns_null_for_nonexistant_items(): void { - $config = new Config(); + $config = new Config; $this->assertNull($config->get('nonexistant-item')); } - public function test_it_returns_a_default_value_for_nonexistant_items() + public function test_it_returns_a_default_value_for_nonexistant_items(): void { - $config = new Config(); + $config = new Config; $this->assertFalse($config->get('nonexistant-item', false)); } - public function test_it_returns_true_if_it_has_an_item() + public function test_it_returns_true_if_it_has_an_item(): void { $config = new Config(['has' => 'some-item']); $this->assertTrue($config->has('has')); } - public function test_it_returns_true_if_it_has_a_boolean_false() + public function test_it_returns_true_if_it_has_a_boolean_false(): void { $config = new Config(['false' => false]); $this->assertTrue($config->has('false')); } - public function test_it_returns_false_if_it_doesnt_have_an_item() + public function test_it_returns_false_if_it_doesnt_have_an_item(): void { - $config = new Config(); + $config = new Config; $this->assertFalse($config->has('nonexistant-item')); } - public function test_it_returns_true_if_it_has_an_item_by_dot_notation() + public function test_it_returns_true_if_it_has_an_item_by_dot_notation(): void { $config = new Config(['foo' => ['bar' => 'foobar']]); $this->assertTrue($config->has('foo.bar')); } - public function test_it_can_load_and_read_additional_files() + public function test_it_can_load_and_read_additional_files(): void { $config = new Config(['driver' => 'sqlite']); @@ -86,16 +85,16 @@ public function test_it_can_load_and_read_additional_files() $this->assertEquals('mysql', $config->get('driver')); } - public function test_it_can_load_additonal_files_with_a_prefix() + public function test_it_can_load_additonal_files_with_a_prefix(): void { - $config = new Config(); + $config = new Config; $config->load(__DIR__ . '/files/php/config.php', 'database'); $this->assertEquals('mysql', $config->get('database.driver')); } - public function test_it_can_load_additional_files_without_overriding_existing_options() + public function test_it_can_load_additional_files_without_overriding_existing_options(): void { $config = new Config(['driver' => 'sqlite']); @@ -104,7 +103,7 @@ public function test_it_can_load_additional_files_without_overriding_existing_op $this->assertEquals('sqlite', $config->get('driver')); } - public function test_it_can_merge_a_config_object() + public function test_it_can_merge_a_config_object(): void { $config = new Config(['foo' => 'foo', 'baz' => 'baz']); $gifnoc = new Config(['bar' => 'rab', 'baz' => 'zab']); @@ -116,7 +115,7 @@ public function test_it_can_merge_a_config_object() $this->assertEquals('zab', $config->get('baz')); } - public function test_it_can_merge_a_config_object_without_overriding_existing_values() + public function test_it_can_merge_a_config_object_without_overriding_existing_values(): void { $config = new Config(['foo' => 'foo', 'baz' => 'baz']); $gifnoc = new Config(['bar' => 'rab', 'baz' => 'zab']); @@ -128,7 +127,7 @@ public function test_it_can_merge_a_config_object_without_overriding_existing_va $this->assertEquals('baz', $config->get('baz')); } - public function test_it_can_split_into_a_sub_object() + public function test_it_can_split_into_a_sub_object(): void { $config = new Config([ 'foo' => 'foo', @@ -143,16 +142,9 @@ public function test_it_can_split_into_a_sub_object() $this->assertNull($bar->get('foo')); } - public function test_it_throws_an_exception_when_initialized_with_an_invalid_context() + public function test_it_can_set_and_retrieve_a_closure(): void { - $this->expectException(InvalidContextException::class); - - new Config(123); - } - - public function test_it_can_set_and_retrieve_a_closure() - { - $config = new Config(); + $config = new Config; $config->set('closure', function ($foo) { return ucwords($foo); @@ -164,7 +156,7 @@ public function test_it_can_set_and_retrieve_a_closure() $this->assertEquals('John Pinkerton', $closure('john pinkerton')); } - public function test_it_can_be_handled_like_an_array() + public function test_it_can_be_handled_like_an_array(): void { $config = new Config(['foo' => 'foo', 'bar' => 'bar']); $config['baz'] = 'baz'; @@ -176,7 +168,7 @@ public function test_it_can_be_handled_like_an_array() $this->assertEquals('baz', $config['baz']); } - public function test_it_can_be_returned_as_an_array() + public function test_it_can_be_returned_as_an_array(): void { $config = new Config([ 'foo' => 'foo', @@ -193,7 +185,7 @@ public function test_it_can_be_returned_as_an_array() ], $config->toArray()); } - public function test_it_is_foreachable() + public function test_it_is_foreachable(): void { $config = new Config([ 'foo' => true, @@ -208,7 +200,7 @@ public function test_it_is_foreachable() } } - public function test_it_can_append_values_to_an_array_item() + public function test_it_can_append_values_to_an_array_item(): void { $config = new Config([ 'app' => [ @@ -227,7 +219,7 @@ public function test_it_can_append_values_to_an_array_item() ], $config->get('app.vars')); } - public function test_it_throws_an_error_when_appending_to_a_non_array_item() + public function test_it_throws_an_error_when_appending_to_a_non_array_item(): void { $config = new Config(['foo' => 'foo']); @@ -236,7 +228,7 @@ public function test_it_throws_an_error_when_appending_to_a_non_array_item() $config->append('foo', 'bar'); } - public function test_it_can_prepend_values_to_an_array_item() + public function test_it_can_prepend_values_to_an_array_item(): void { $config = new Config([ 'app' => [ @@ -255,7 +247,7 @@ public function test_it_can_prepend_values_to_an_array_item() ], $config->get('app.vars')); } - public function test_it_throws_an_error_when_prepending_to_a_non_array_item() + public function test_it_throws_an_error_when_prepending_to_a_non_array_item(): void { $config = new Config(['foo' => 'foo']); @@ -264,7 +256,7 @@ public function test_it_throws_an_error_when_prepending_to_a_non_array_item() $config->prepend('foo', 'bar'); } - public function test_it_can_be_instantiated_with_prefixes() + public function test_it_can_be_instantiated_with_prefixes(): void { $config = Config::fromDirectory(__DIR__ . '/prefix_test'); diff --git a/tests/DirectoryTest.php b/tests/DirectoryTest.php index 5a841f1..45f179f 100644 --- a/tests/DirectoryTest.php +++ b/tests/DirectoryTest.php @@ -8,7 +8,7 @@ /** @covers \PHLAK\Config\Loaders\Directory */ class DirectoryTest extends TestCase { - public function test_it_can_initialize_a_directory() + public function test_it_can_initialize_a_directory(): void { $config = new Config(__DIR__ . '/files'); @@ -16,7 +16,7 @@ public function test_it_can_initialize_a_directory() $this->assertEquals('mysql', $config->get('driver')); } - public function test_it_can_initialize_an_array_with_a_prefix() + public function test_it_can_initialize_an_array_with_a_prefix(): void { $config = new Config(__DIR__ . '/files', 'database'); diff --git a/tests/TomlTest.php b/tests/TomlTest.php index e0f0292..e1962ec 100644 --- a/tests/TomlTest.php +++ b/tests/TomlTest.php @@ -18,7 +18,7 @@ protected function setUp(): void $this->invalidConfig = __DIR__ . '/files/toml/invalid.toml'; } - public function test_it_throws_an_exception_when_initializing_a_toml_file_without_an_array() + public function test_it_throws_an_exception_when_initializing_a_toml_file_without_an_array(): void { $this->expectException(InvalidFileException::class); diff --git a/tests/Traits/Initializable.php b/tests/Traits/Initializable.php index f4dc38c..ec8a75e 100644 --- a/tests/Traits/Initializable.php +++ b/tests/Traits/Initializable.php @@ -14,7 +14,7 @@ trait Initializable /** @var string Path to an invalid config file */ protected $invalidConfig; - public function test_it_can_initialize_a_file() + public function test_it_can_initialize_a_file(): void { $config = new Config($this->validConfig); @@ -22,7 +22,7 @@ public function test_it_can_initialize_a_file() $this->assertEquals('database.sqlite', $config->get('drivers.sqlite.database')); } - public function test_it_throws_an_exception_when_initializing_an_invalid_file() + public function test_it_throws_an_exception_when_initializing_an_invalid_file(): void { $this->expectException(ConfigException::class); $this->expectException(InvalidFileException::class); @@ -30,7 +30,7 @@ public function test_it_throws_an_exception_when_initializing_an_invalid_file() new Config($this->invalidConfig); } - public function test_it_can_initialize_a_file_with_a_prefix() + public function test_it_can_initialize_a_file_with_a_prefix(): void { $config = new Config($this->validConfig, 'database'); diff --git a/tests/YamlTest.php b/tests/YamlTest.php index 3dfbc3a..78e26b4 100644 --- a/tests/YamlTest.php +++ b/tests/YamlTest.php @@ -18,7 +18,7 @@ protected function setUp(): void $this->invalidConfig = __DIR__ . '/files/yaml/invalid.yaml'; } - public function test_it_throws_an_exception_when_initializing_a_yaml_file_without_an_array() + public function test_it_throws_an_exception_when_initializing_a_yaml_file_without_an_array(): void { $this->expectException(InvalidFileException::class);