diff --git a/.docker/php/Dockerfile b/.docker/php/Dockerfile deleted file mode 100644 index a3a7de4..0000000 --- a/.docker/php/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -ARG PHP_VERSION=8.3 - -FROM php:${PHP_VERSION}-alpine - -# Install system dependencies -RUN apk update && apk add --no-cache \ - $PHPIZE_DEPS \ - linux-headers \ - zlib-dev \ - libmemcached-dev \ - cyrus-sasl-dev - -RUN pecl install xdebug redis memcached \ - && docker-php-ext-enable xdebug redis memcached - -# Copy custom PHP configuration -COPY .docker/php/kariricode-php.ini /usr/local/etc/php/conf.d/ - -# Instalação do Composer -RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - -RUN apk del --purge $PHPIZE_DEPS && rm -rf /var/cache/apk/* - -# Mantém o contêiner ativo sem fazer nada -CMD tail -f /dev/null diff --git a/.docker/php/kariricode-php.ini b/.docker/php/kariricode-php.ini deleted file mode 100644 index 9e90446..0000000 --- a/.docker/php/kariricode-php.ini +++ /dev/null @@ -1,14 +0,0 @@ -[PHP] -memory_limit = 256M -upload_max_filesize = 50M -post_max_size = 50M -date.timezone = America/Sao_Paulo - -[Xdebug] -; zend_extension=xdebug.so -xdebug.mode=debug -xdebug.start_with_request=yes -xdebug.client_host=host.docker.internal -xdebug.client_port=9003 -xdebug.log=/tmp/xdebug.log -xdebug.idekey=VSCODE diff --git a/.env.example b/.env.example deleted file mode 100644 index fba5ae4..0000000 --- a/.env.example +++ /dev/null @@ -1,30 +0,0 @@ -KARIRI_APP_ENV=develop -KARIRI_APP_NAME=KaririCode - -KARIRI_PHP_VERSION=8.3 -KARIRI_PHP_PORT=9003 - -KARIRI_APP_DEBUG=false -KARIRI_APP_URL=https://kariricode.com - - -KARIRI_DB_CONNECTION=mysql -KARIRI_DB_HOST=127.0.0.1 -KARIRI_DB_PORT=3306 -KARIRI_DB_DATABASE=kariricode -KARIRI_DB_USERNAME=root -KARIRI_DB_PASSWORD=secret - -KARIRI_CACHE_DRIVER=redis -KARIRI_SESSION_LIFETIME=120 - -KARIRI_MAIL_MAILER=smtp -KARIRI_MAIL_HOST=mailhog -KARIRI_MAIL_PORT=1025 -KARIRI_MAIL_USERNAME=null -KARIRI_MAIL_PASSWORD=null -KARIRI_MAIL_ENCRYPTION=null -KARIRI_MAIL_FROM_ADDRESS=null -KARIRI_MAIL_FROM_NAME="${KARIRI_APP_NAME}" - -KARIRI_JSON_CONFIG={"key": "value", "nested": {"subkey": "subvalue"}} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5e5baad..2c36f2d 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ tests/lista_de_arquivos_test.php lista_de_arquivos.txt lista_de_arquivos_tests.txt add_static_to_providers.php + +# KaririCode Devkit — generated configs and build artifacts +.kcode/ diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php deleted file mode 100644 index c3a51bb..0000000 --- a/.php-cs-fixer.php +++ /dev/null @@ -1,69 +0,0 @@ -in(__DIR__ . '/src') - ->in(__DIR__ . '/tests') - ->exclude('var') - ->exclude('config') - ->exclude('vendor'); - -return (new PhpCsFixer\Config()) - ->setParallelConfig(new PhpCsFixer\Runner\Parallel\ParallelConfig(4, 20)) - ->setRules([ - '@PSR12' => true, - '@Symfony' => true, - 'full_opening_tag' => false, - 'phpdoc_var_without_name' => false, - 'phpdoc_to_comment' => false, - 'array_syntax' => ['syntax' => 'short'], - 'concat_space' => ['spacing' => 'one'], - 'binary_operator_spaces' => [ - 'default' => 'single_space', - 'operators' => [ - '=' => 'single_space', - '=>' => 'single_space', - ], - ], - 'blank_line_before_statement' => [ - 'statements' => ['return'] - ], - 'cast_spaces' => ['space' => 'single'], - 'class_attributes_separation' => [ - 'elements' => [ - 'const' => 'none', - 'method' => 'one', - 'property' => 'none' - ] - ], - 'declare_equal_normalize' => ['space' => 'none'], - 'function_typehint_space' => true, - 'lowercase_cast' => true, - 'no_unused_imports' => true, - 'not_operator_with_successor_space' => true, - 'ordered_imports' => true, - 'phpdoc_align' => ['align' => 'left'], - 'phpdoc_no_alias_tag' => ['replacements' => ['type' => 'var', 'link' => 'see']], - 'phpdoc_order' => true, - 'phpdoc_scalar' => true, - 'single_quote' => true, - 'standardize_not_equals' => true, - 'trailing_comma_in_multiline' => ['elements' => ['arrays']], - 'trim_array_spaces' => true, - 'space_after_semicolon' => true, - 'no_spaces_inside_parenthesis' => true, - 'no_whitespace_before_comma_in_array' => true, - 'whitespace_after_comma_in_array' => true, - 'visibility_required' => ['elements' => ['const', 'method', 'property']], - 'multiline_whitespace_before_semicolons' => [ - 'strategy' => 'no_multi_line', - ], - 'method_chaining_indentation' => true, - 'class_definition' => [ - 'single_item_single_line' => false, - 'multi_line_extends_each_single_line' => true, - ], - 'not_operator_with_successor_space' => false - ]) - ->setRiskyAllowed(true) - ->setFinder($finder) - ->setUsingCache(false); diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 38f7f80..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "[php]": { - "editor.defaultFormatter": "junstyle.php-cs-fixer" - }, - "php-cs-fixer.executablePath": "${workspaceFolder}/vendor/bin/php-cs-fixer", - "php-cs-fixer.onsave": true, - "php-cs-fixer.rules": "@PSR12", - "php-cs-fixer.config": ".php_cs.dist", - "php-cs-fixer.formatHtml": true -} diff --git a/Makefile b/Makefile deleted file mode 100644 index f6efe50..0000000 --- a/Makefile +++ /dev/null @@ -1,174 +0,0 @@ -# Initial configurations -PHP_SERVICE := kariricode-dotenv -DC := docker-compose - -# Command to execute commands inside the PHP container -EXEC_PHP := $(DC) exec -T php - -# Icons -CHECK_MARK := ✅ -WARNING := ⚠️ -INFO := ℹ️ - -# Colors -RED := \033[0;31m -GREEN := \033[0;32m -YELLOW := \033[1;33m -NC := \033[0m # No Color - -# Check if Docker is installed -CHECK_DOCKER := @command -v docker > /dev/null 2>&1 || { echo >&2 "${YELLOW}${WARNING} Docker is not installed. Aborting.${NC}"; exit 1; } -# Check if Docker Compose is installed -CHECK_DOCKER_COMPOSE := @command -v docker-compose > /dev/null 2>&1 || { echo >&2 "${YELLOW}${WARNING} Docker Compose is not installed. Aborting.${NC}"; exit 1; } -# Function to check if the container is running -CHECK_CONTAINER_RUNNING := @docker ps | grep $(PHP_SERVICE) > /dev/null 2>&1 || { echo >&2 "${YELLOW}${WARNING} The container $(PHP_SERVICE) is not running. Run 'make up' to start it.${NC}"; exit 1; } -# Check if the .env file exists -CHECK_ENV := @test -f .env || { echo >&2 "${YELLOW}${WARNING} .env file not found. Run 'make setup-env' to configure.${NC}"; exit 1; } - -## setup-env: Copy .env.example to .env if the latter does not exist -setup-env: - @test -f .env || (cp .env.example .env && echo "${GREEN}${CHECK_MARK} .env file created successfully from .env.example${NC}") - -check-environment: - @echo "${GREEN}${INFO} Checking Docker, Docker Compose, and .env file...${NC}" - $(CHECK_DOCKER) - $(CHECK_DOCKER_COMPOSE) - $(CHECK_ENV) - -check-container-running: - $(CHECK_CONTAINER_RUNNING) - -## up: Start all services in the background -up: check-environment - @echo "${GREEN}${INFO} Starting services...${NC}" - @$(DC) up -d - @echo "${GREEN}${CHECK_MARK} Services are up!${NC}" - -## down: Stop and remove all containers -down: check-environment - @echo "${YELLOW}${INFO} Stopping and removing services...${NC}" - @$(DC) down - @echo "${GREEN}${CHECK_MARK} Services stopped and removed!${NC}" - -## build: Build Docker images -build: check-environment - @echo "${YELLOW}${INFO} Building services...${NC}" - @$(DC) build - @echo "${GREEN}${CHECK_MARK} Services built!${NC}" - -## logs: Show container logs -logs: check-environment - @echo "${YELLOW}${INFO} Container logs:${NC}" - @$(DC) logs - -## re-build: Rebuild and restart containers -re-build: check-environment - @echo "${YELLOW}${INFO} Stopping and removing current services...${NC}" - @$(DC) down - @echo "${GREEN}${INFO} Rebuilding services...${NC}" - @$(DC) build - @echo "${GREEN}${INFO} Restarting services...${NC}" - @$(DC) up -d - @echo "${GREEN}${CHECK_MARK} Services rebuilt and restarted successfully!${NC}" - @$(DC) logs - -## shell: Access the shell of the PHP container -shell: check-environment check-container-running - @echo "${GREEN}${INFO} Accessing the shell of the PHP container...${NC}" - @$(DC) exec php sh - -## composer-install: Install Composer dependencies. Use make composer-install [PKG="[vendor/package [version]]"] [DEV="--dev"] -composer-install: check-environment check-container-running - @echo "${GREEN}${INFO} Installing Composer dependencies...${NC}" - @if [ -z "$(PKG)" ]; then \ - $(EXEC_PHP) composer install; \ - else \ - $(EXEC_PHP) composer require $(PKG) $(DEV); \ - fi - @echo "${GREEN}${CHECK_MARK} Composer operation completed!${NC}" - -## composer-remove: Remove Composer dependencies. Usage: make composer-remove PKG="vendor/package" -composer-remove: check-environment check-container-running - @if [ -z "$(PKG)" ]; then \ - echo "${RED}${WARNING} You must specify a package to remove. Usage: make composer-remove PKG=\"vendor/package\"${NC}"; \ - else \ - $(EXEC_PHP) composer remove $(PKG); \ - echo "${GREEN}${CHECK_MARK} Package $(PKG) removed successfully!${NC}"; \ - fi - -## composer-update: Update Composer dependencies -composer-update: check-environment check-container-running - @echo "${GREEN}${INFO} Updating Composer dependencies...${NC}" - $(EXEC_PHP) composer update - @echo "${GREEN}${CHECK_MARK} Dependencies updated!${NC}" - -## test: Run tests -test: check-environment check-container-running - @echo "${GREEN}${INFO} Running tests...${NC}" - $(EXEC_PHP) ./vendor/bin/phpunit --testdox --colors=always tests - @echo "${GREEN}${CHECK_MARK} Tests completed!${NC}" - -## test-file: Run tests on a specific class. Usage: make test-file FILE=[file] -test-file: check-environment check-container-running - @echo "${GREEN}${INFO} Running test for class $(FILE)...${NC}" - $(EXEC_PHP) ./vendor/bin/phpunit --testdox --colors=always tests/$(FILE) - @echo "${GREEN}${CHECK_MARK} Test for class $(FILE) completed!${NC}" - -## coverage: Run test coverage with visual formatting -coverage: check-environment check-container-running - @echo "${GREEN}${INFO} Analyzing test coverage...${NC}" - XDEBUG_MODE=coverage $(EXEC_PHP) ./vendor/bin/phpunit --coverage-text --colors=always tests | ccze -A - -## coverage-html: Run test coverage and generate HTML report -coverage-html: check-environment check-container-running - @echo "${GREEN}${INFO} Analyzing test coverage and generating HTML report...${NC}" - XDEBUG_MODE=coverage $(EXEC_PHP) ./vendor/bin/phpunit --coverage-html ./coverage-report-html tests - @echo "${GREEN}${INFO} Test coverage report generated in ./coverage-report-html${NC}" - -## run-script: Run a PHP script. Usage: make run-script SCRIPT="path/to/script.php" -run-script: check-environment check-container-running - @echo "${GREEN}${INFO} Running script: $(SCRIPT)...${NC}" - $(EXEC_PHP) php $(SCRIPT) - @echo "${GREEN}${CHECK_MARK} Script executed!${NC}" - -## cs-check: Run PHP_CodeSniffer to check code style -cs-check: check-environment check-container-running - @echo "${GREEN}${INFO} Checking code style...${NC}" - $(EXEC_PHP) ./vendor/bin/php-cs-fixer fix --dry-run --diff - @echo "${GREEN}${CHECK_MARK} Code style check completed!${NC}" - -## cs-fix: Run PHP CS Fixer to fix code style -cs-fix: check-environment check-container-running - @echo "${GREEN}${INFO} Fixing code style with PHP CS Fixer...${NC}" - $(EXEC_PHP) ./vendor/bin/php-cs-fixer fix - @echo "${GREEN}${CHECK_MARK} Code style fixed!${NC}" - -## security-check: Check for security vulnerabilities in dependencies -security-check: check-environment check-container-running - @echo "${GREEN}${INFO} Checking for security vulnerabilities with Security Checker...${NC}" - $(EXEC_PHP) ./vendor/bin/security-checker security:check - @echo "${GREEN}${CHECK_MARK} Security check completed!${NC}" - -## quality: Run all quality commands -quality: check-environment check-container-running cs-check test security-check - @echo "${GREEN}${CHECK_MARK} All quality commands executed!${NC}" - -## help: Show initial setup steps and available commands -help: - @echo "${GREEN}Initial setup steps for configuring the project:${NC}" - @echo "1. ${YELLOW}Initial environment setup:${NC}" - @echo " ${GREEN}${CHECK_MARK} Copy the environment file:${NC} make setup-env" - @echo " ${GREEN}${CHECK_MARK} Start the Docker containers:${NC} make up" - @echo " ${GREEN}${CHECK_MARK} Install Composer dependencies:${NC} make composer-install" - @echo "2. ${YELLOW}Development:${NC}" - @echo " ${GREEN}${CHECK_MARK} Access the PHP container shell:${NC} make shell" - @echo " ${GREEN}${CHECK_MARK} Run a PHP script:${NC} make run-script SCRIPT=\"script_name.php\"" - @echo " ${GREEN}${CHECK_MARK} Run the tests:${NC} make test" - @echo "3. ${YELLOW}Maintenance:${NC}" - @echo " ${GREEN}${CHECK_MARK} Update Composer dependencies:${NC} make composer-update" - @echo " ${GREEN}${CHECK_MARK} Clear the application cache:${NC} make cache-clear" - @echo " ${RED}${WARNING} Stop and remove all Docker containers:${NC} make down" - @echo "\n${GREEN}Available commands:${NC}" - @sed -n 's/^##//p' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ": "}; {printf "${YELLOW}%-30s${NC} %s\n", $$1, $$2}' - -.PHONY: setup-env up down build logs re-build shell composer-install composer-remove composer-update test test-file coverage coverage-html run-script cs-check cs-fix security-check quality help diff --git a/README.md b/README.md index 85202f8..105fe1f 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,116 @@ -# KaririCode Framework: Dotenv Component +# KaririCode Dotenv -[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md) [![pt-br](https://img.shields.io/badge/lang-pt--br-green.svg)](README.pt-br.md) +
-![PHP](https://img.shields.io/badge/PHP-777BB4?style=for-the-badge&logo=php&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white) ![PHPUnit](https://img.shields.io/badge/PHPUnit-3776AB?style=for-the-badge&logo=php&logoColor=white) +[![PHP 8.4+](https://img.shields.io/badge/PHP-8.4%2B-777BB4?logo=php&logoColor=white)](https://www.php.net/) +[![License: MIT](https://img.shields.io/badge/License-MIT-22c55e.svg)](LICENSE) +[![PHPStan Level 9](https://img.shields.io/badge/PHPStan-Level%209-4F46E5)](https://phpstan.org/) +[![Tests](https://img.shields.io/badge/Tests-205%20passing-22c55e)](https://kariricode.org) +[![Zero Dependencies](https://img.shields.io/badge/Dependencies-0-22c55e)](composer.json) +[![ARFA](https://img.shields.io/badge/ARFA-1.3-orange)](https://kariricode.org) +[![KaririCode Framework](https://img.shields.io/badge/KaririCode-Framework-orange)](https://kariricode.org) -A robust and flexible environment variable management component for the KaririCode Framework, providing advanced features for handling .env files in PHP applications. +**The only PHP dotenv with AES-256-GCM encryption, OPcache caching, fluent validation DSL, +environment-aware cascade loading, and auto type casting — zero dependencies, PHP 8.4+.** -## Features +[Installation](#installation) · [Quick Start](#quick-start) · [Features](#features) · [Validation DSL](#validation-dsl) · [Encryption](#encryption) · [Architecture](#architecture) -- Parse and load environment variables from .env files -- Support for variable interpolation -- **Automatic type detection and casting** - - Detects and converts common types (string, integer, float, boolean, array, JSON) - - Preserves data types for more accurate usage in your application -- **Customizable type system** - - Extensible with custom type detectors and casters - - Fine-grained control over how your environment variables are processed -- Strict mode for variable name validation -- Easy access to environment variables through a global helper function -- Support for complex data structures (arrays and JSON) in environment variables +
-## Installation +--- -To install the KaririCode Dotenv component in your project, run the following command: +## The Problem -```bash -composer require kariricode/dotenv +Every PHP project reinvents the same wheel: + +```php +// No type safety — you get raw strings everywhere +$_ENV['DB_PORT'] // "5432" (string, not int) +$_ENV['DEBUG'] // "true" (string, not bool) + +// No validation — missing vars discovered at runtime +// No encryption — secrets sit as plaintext in .env files +// No cascade — can't load .env.local over .env automatically ``` -## Usage +## The Solution -### Basic Usage +```php +$dotenv = new Dotenv(__DIR__); +$dotenv->load(); -1. Create a `.env` file in your project's root directory: +// Auto-typed +env('DB_PORT'); // 5432 (int) +env('DEBUG'); // true (bool) + +// Validated before service boot +$dotenv->validate() + ->required('DB_HOST', 'DB_PORT') + ->isInteger('DB_PORT')->between(1, 65535) + ->url('APP_URL') + ->email('ADMIN_EMAIL') + ->assert(); + +// Encrypted secrets — transparent decryption +// SECRET=encrypted:base64data... +$dotenv->get('SECRET'); // "my-actual-secret" +``` -```env -KARIRI_APP_ENV=develop -KARIRI_APP_NAME=KaririCode -KARIRI_PHP_VERSION=8.3 -KARIRI_PHP_PORT=9003 -KARIRI_APP_DEBUG=true -KARIRI_APP_URL=https://kariricode.com -KARIRI_MAIL_FROM_NAME="${KARIRI_APP_NAME}" -KARIRI_JSON_CONFIG={"key": "value", "nested": {"subkey": "subvalue"}} -KARIRI_ARRAY_CONFIG=["item1", "item2", "item with spaces"] +--- + +## Requirements + +| Requirement | Version | +|---|---| +| PHP | 8.4 or higher | +| ext-openssl | Optional (required for encryption) | + +--- + +## Installation + +```bash +composer require kariricode/dotenv ``` -2. In your application's bootstrap file: +--- + +## Quick Start + +```bash +# 1. Create your .env +APP_ENV=production +APP_URL=https://myapp.com +DB_HOST=localhost +DB_PORT=5432 +APP_DEBUG=false +``` ```php load(); -// Now you can use the env() function to access your environment variables -$appName = env('KARIRI_APP_NAME'); -$debug = env('KARIRI_APP_DEBUG'); -$jsonConfig = env('KARIRI_JSON_CONFIG'); -$arrayConfig = env('KARIRI_ARRAY_CONFIG'); +// Typed access via helper +$port = env('DB_PORT'); // int: 5432 +$debug = env('APP_DEBUG'); // bool: false +$host = env('DB_HOST'); // string: "localhost" ``` -### Type Detection and Casting +--- -The KaririCode Dotenv component automatically detects and casts the following types: +## Features -- Strings -- Integers -- Floats -- Booleans -- Null values -- Arrays -- JSON objects +### Auto Type Casting -Example: +All values are automatically cast to their native PHP type: ```env STRING_VAR=Hello World @@ -86,133 +118,293 @@ INT_VAR=42 FLOAT_VAR=3.14 BOOL_VAR=true NULL_VAR=null -ARRAY_VAR=["item1", "item2", "item3"] JSON_VAR={"key": "value", "nested": {"subkey": "subvalue"}} +ARRAY_VAR=["item1", "item2", "item3"] +``` + +```php +env('STRING_VAR'); // string: "Hello World" +env('INT_VAR'); // int: 42 +env('FLOAT_VAR'); // float: 3.14 +env('BOOL_VAR'); // bool: true +env('NULL_VAR'); // null +env('JSON_VAR'); // array: ["key" => "value", "nested" => [...]] +env('ARRAY_VAR'); // array: ["item1", "item2", "item3"] ``` -When accessed using the `env()` function, these variables will be automatically cast to their appropriate PHP types: +### Variable Interpolation + +```env +APP_NAME=KaririCode +GREETING="Welcome to ${APP_NAME}" # → "Welcome to KaririCode" +HAS_REDIS=${REDIS_HOST:+yes} # "yes" if REDIS_HOST is set +FALLBACK=${MISSING_VAR:-default-value} # "default-value" if unset +``` + +### Load Modes ```php -$stringVar = env('STRING_VAR'); // string: "Hello World" -$intVar = env('INT_VAR'); // integer: 42 -$floatVar = env('FLOAT_VAR'); // float: 3.14 -$boolVar = env('BOOL_VAR'); // boolean: true -$nullVar = env('NULL_VAR'); // null -$arrayVar = env('ARRAY_VAR'); // array: ["item1", "item2", "item3"] -$jsonVar = env('JSON_VAR'); // array: ["key" => "value", "nested" => ["subkey" => "subvalue"]] +use KaririCode\Dotenv\Enum\LoadMode; +use KaririCode\Dotenv\ValueObject\DotenvConfiguration; + +// Immutable (default) — skip vars already in environment +$config = new DotenvConfiguration(loadMode: LoadMode::Immutable); + +// SkipExisting — keep existing $_ENV values, skip .env values +$config = new DotenvConfiguration(loadMode: LoadMode::SkipExisting); + +// Overwrite — .env always wins +$config = new DotenvConfiguration(loadMode: LoadMode::Overwrite); ``` -This automatic typing ensures that you're working with the correct data types in your application, reducing type-related errors and improving overall code reliability. +### Environment Cascade (`bootEnv`) + +Loads files in priority order, later files overriding earlier ones: -### Advanced Usage +``` +.env → .env.local → .env.{APP_ENV} → .env.{APP_ENV}.local +``` + +```php +$dotenv = new Dotenv(__DIR__, new DotenvConfiguration(loadMode: LoadMode::Overwrite)); +$dotenv->bootEnv(); // reads APP_ENV from environment automatically +$dotenv->bootEnv('staging'); // explicit environment +``` -#### Custom Type Detectors +> `.env.test.local` is **always skipped** when `APP_ENV=test` — ensuring reproducible test runs. -Create custom type detectors to handle specific formats: +### Multiple Files ```php -use KaririCode\Dotenv\Type\Detector\AbstractTypeDetector; +$dotenv = new Dotenv(__DIR__, $config, '.env', '.env.local'); +$dotenv->load(); +``` -class CustomDetector extends AbstractTypeDetector -{ - public const PRIORITY = 100; +### Allow / Deny Lists (glob patterns) - public function detect(mixed $value): ?string - { - // Your detection logic here - // Return the detected type as a string, or null if not detected - } -} +```php +// Only load DB_* variables +$config = new DotenvConfiguration(allowList: ['DB_*']); -$dotenv->addTypeDetector(new CustomDetector()); +// Load everything except SECRET* +$config = new DotenvConfiguration(denyList: ['SECRET*']); ``` -#### Custom Type Casters +--- + +## Validation DSL -Create custom type casters to handle specific data types: +Fluent, chainable validation with **all errors collected before throwing** (no fail-fast): ```php -use KaririCode\Dotenv\Contract\Type\TypeCaster; +$dotenv->validate() + ->required('DB_HOST', 'DB_PORT', 'APP_ENV') + ->notEmpty('DB_HOST') + ->isInteger('DB_PORT')->between(1, 65535) + ->isBoolean('APP_DEBUG') + ->allowedValues('APP_ENV', ['local', 'staging', 'production']) + ->url('APP_URL') + ->email('ADMIN_EMAIL') + ->matchesRegex('BUILD_SHA', '/^[a-f0-9]{40}$/') + ->ifPresent('REDIS_HOST')->notEmpty() + ->custom('DB_DSN', fn(string $v): bool => str_starts_with($v, 'pgsql:')) + ->assert(); // throws ValidationException with ALL failures at once +``` -class CustomCaster implements TypeCaster -{ - public function cast(mixed $value): mixed - { - // Your casting logic here - } -} +### Schema-Based Validation (`.env.schema`) -$dotenv->addTypeCaster('custom_type', new CustomCaster()); +```ini +[DB_HOST] +required = true +notEmpty = true + +[DB_PORT] +required = true +type = integer +min = 1 +max = 65535 + +[APP_ENV] +required = true +allowed = local, staging, production ``` -## Development and Testing +```php +$dotenv->loadWithSchema('/path/to/.env.schema'); +``` -For development and testing purposes, this package uses Docker and Docker Compose to ensure consistency across different environments. A Makefile is provided for convenience. +--- + +## Encryption -### Prerequisites +AES-256-GCM authenticated encryption for secrets. Encrypted values use the `encrypted:` prefix and are **transparently decrypted** on load. -- Docker -- Docker Compose -- Make (optional, but recommended for easier command execution) +```php +use KaririCode\Dotenv\Security\KeyPair; +use KaririCode\Dotenv\Security\Encryptor; -### Setup for Development +// Generate a key pair (store the private key securely!) +$keyPair = KeyPair::generate(); +$encryptor = new Encryptor($keyPair->privateKey); -1. Clone the repository: +// Encrypt a secret +$encrypted = $encryptor->encrypt('my-secret-password'); +// → "encrypted:base64encodedpayload..." +``` + +```env +# .env — commit this (value is opaque ciphertext) +DB_PASSWORD=encrypted:aGVsbG8gd29ybGQ... +``` + +```php +// Decryption happens transparently during load +$config = new DotenvConfiguration(encryptionKey: $keyPair->privateKey); +$dotenv = new Dotenv(__DIR__, $config); +$dotenv->load(); - ```bash - git clone https://github.com/KaririCode-Framework/kariricode-dotenv.git - cd kariricode-dotenv - ``` +$dotenv->get('DB_PASSWORD'); // "my-secret-password" +``` -2. Set up the environment: +--- - ```bash - make setup-env - ``` +## OPcache Caching -3. Start the Docker containers: +Compile parsed variables into an OPcache-friendly PHP file. Subsequent requests load from shared memory with **zero parsing cost**: - ```bash - make up - ``` +```php +$dotenv->load(); +$dotenv->dumpCache('/path/to/.env.cache.php'); -4. Install dependencies: - ```bash - make composer-install - ``` +// Next request: +$config = new DotenvConfiguration(cachePath: '/path/to/.env.cache.php'); +$dotenv = new Dotenv(__DIR__, $config); +$dotenv->load(); // loaded from OPcache — no file I/O +``` -### Available Make Commands +--- -- `make up`: Start all services in the background -- `make down`: Stop and remove all containers -- `make build`: Build Docker images -- `make shell`: Access the shell of the PHP container -- `make test`: Run tests -- `make coverage`: Run test coverage with visual formatting -- `make cs-fix`: Run PHP CS Fixer to fix code style -- `make quality`: Run all quality commands (cs-check, test, security-check) +## Variable Processors -For a full list of available commands, run: +Transform values after parsing, with **glob pattern matching** for key selection: -```bash -make help +```php +use KaririCode\Dotenv\Processor\CsvToArrayProcessor; +use KaririCode\Dotenv\Processor\UrlNormalizerProcessor; +use KaririCode\Dotenv\Processor\TrimProcessor; +use KaririCode\Dotenv\Processor\Base64DecodeProcessor; + +$dotenv->addProcessor('ALLOWED_IPS', new CsvToArrayProcessor()); // "a, b, c" → ["a","b","c"] +$dotenv->addProcessor('*_URL', new UrlNormalizerProcessor()); // glob: all *_URL keys +$dotenv->addProcessor('API_TOKEN', new Base64DecodeProcessor()); +$dotenv->addProcessor('DB_HOST', new TrimProcessor()); ``` -## License +--- + +## Debug & Introspection + +```php +$dotenv->load(); + +// Source tracking — where did each variable come from? +$debug = $dotenv->debug(); +// ['DB_HOST' => ['source' => '.env.local', 'type' => 'String', 'value' => 'localhost', 'overridden' => true]] + +// All loaded variables as EnvironmentVariable value objects +$vars = $dotenv->variables(); + +// Safe load — skip missing files instead of throwing +$dotenv->safeLoad(); +``` + +--- + +## Architecture + +### Source layout + +``` +src/ +├── Cache/ OPcache-friendly PHP file cache (PhpFileCache) +├── Contract/ Interfaces: TypeCaster · TypeDetector · ValidationRule · VariableProcessor +├── Core/ DotenvParser — full .env syntax support +├── Dotenv.php Main facade — load · validate · encrypt · cache · bootEnv +├── Enum/ LoadMode · ValueType +├── Exception/ DotenvException hierarchy (5 classes) +├── Processor/ CsvToArray · UrlNormalizer · Trim · Base64Decode +├── Schema/ SchemaParser — .env.schema declarative validation +├── Security/ Encryptor (AES-256-GCM) · KeyPair +├── Type/ TypeSystem + 6 Detectors + 6 Casters +├── Validation/ EnvironmentValidator (fluent DSL) + 10 Rule classes +├── ValueObject/ DotenvConfiguration (immutable) · EnvironmentVariable (immutable) +└── env.php Global env() helper +``` -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +### Key design decisions + +| Decision | Rationale | ADR | +|---|---|---| +| Zero dependencies | No version conflicts, sub-ms boot | [ADR-001](docs/adr/ADR-001-zero-dependencies.md) | +| Immutable configuration | Thread-safe, ARFA 1.3 compliant | [ADR-002](docs/adr/ADR-002-immutable-configuration.md) | +| Pluggable type system | Extend without modifying framework code | [ADR-003](docs/adr/ADR-003-type-system.md) | +| AES-256-GCM encryption | Authenticated encryption, nonce-per-value | [ADR-004](docs/adr/ADR-004-encryption-format.md) | +| OPcache caching | Zero parse overhead in production | [ADR-005](docs/adr/ADR-005-opcache-cache.md) | +| Fluent validation DSL | Collect all errors before throwing | [ADR-008](docs/adr/ADR-008-validation-strategy.md) | + +### Specifications + +| Spec | Covers | +|---|---| +| [SPEC-001](docs/spec/SPEC-001-env-syntax.md) | `.env` file syntax and parsing rules | +| [SPEC-002](docs/spec/SPEC-002-type-system.md) | Type detection and casting | +| [SPEC-003](docs/spec/SPEC-003-encryption.md) | Encryption format and key derivation | +| [SPEC-004](docs/spec/SPEC-004-validation.md) | Validation DSL API | +| [SPEC-005](docs/spec/SPEC-005-schema-format.md) | `.env.schema` format | +| [SPEC-006](docs/spec/SPEC-006-processors.md) | Variable processor contract | +| [SPEC-007](docs/spec/SPEC-007-cli.md) | CLI tooling | -## Support and Community +--- -- **Documentation**: [https://kariricode.org/docs/dotenv](https://kariricode.org/docs/dotenv) -- **Issue Tracker**: [GitHub Issues](https://github.com/KaririCode-Framework/kariricode-dotenv/issues) -- **Community**: [KaririCode Club Community](https://kariricode.club) +## Project Stats + +| Metric | Value | +|---|---| +| PHP source files | 38 | +| External runtime dependencies | 0 | +| Test suite | 205 tests · 396 assertions | +| PHPStan level | 9 | +| PHP version | 8.4+ | +| ARFA compliance | 1.3 | +| Encryption | AES-256-GCM | +| Type detection | 7 built-in types (extensible) | +| Validation rules | 10 built-in rules (extensible) | +| Variable processors | 4 built-in (extensible) | -## Acknowledgments +--- -- The KaririCode Framework team and contributors. -- Inspired by other popular PHP Dotenv libraries. +## Contributing + +```bash +git clone https://github.com/KaririCode-Framework/kariricode-dotenv.git +cd kariricode-dotenv +composer install +kcode init +kcode quality # Must pass before opening a PR +``` --- -Built with ❤️ by the KaririCode team. Empowering developers to build more robust and flexible PHP applications. +## License + +[MIT License](LICENSE) © [Walmir Silva](mailto:community@kariricode.org) + +--- + +
+ +Part of the **[KaririCode Framework](https://kariricode.org)** ecosystem. + +[kariricode.org](https://kariricode.org) · [GitHub](https://github.com/KaririCode-Framework/kariricode-dotenv) · [Packagist](https://packagist.org/packages/kariricode/dotenv) · [Issues](https://github.com/KaririCode-Framework/kariricode-dotenv/issues) + +
diff --git a/README.pt-br.md b/README.pt-br.md deleted file mode 100644 index 57ed705..0000000 --- a/README.pt-br.md +++ /dev/null @@ -1,218 +0,0 @@ -# KaririCode Framework: Componente Dotenv - -[![en](https://img.shields.io/badge/lang-en-red.svg)](README.md) [![pt-br](https://img.shields.io/badge/lang-pt--br-green.svg)](README.pt-br.md) - -![PHP](https://img.shields.io/badge/PHP-777BB4?style=for-the-badge&logo=php&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white) ![PHPUnit](https://img.shields.io/badge/PHPUnit-3776AB?style=for-the-badge&logo=php&logoColor=white) - -Um componente robusto e flexível para gerenciamento de variáveis de ambiente no KaririCode Framework, fornecendo recursos avançados para lidar com arquivos .env em aplicações PHP. - -## Funcionalidades - -- Parse e carregamento de variáveis de ambiente a partir de arquivos .env -- Suporte para interpolação de variáveis -- **Detecção e conversão automática de tipos** - - Detecta e converte tipos comuns (string, inteiro, float, booleano, array, JSON) - - Preserva tipos de dados para uso mais preciso em sua aplicação -- **Sistema de tipos personalizável** - - Extensível com detectores e conversores de tipos personalizados - - Controle refinado sobre como suas variáveis de ambiente são processadas -- Modo estrito para validação de nomes de variáveis -- Acesso fácil às variáveis de ambiente por meio de uma função auxiliar global -- Suporte para estruturas de dados complexas (arrays e JSON) em variáveis de ambiente - -## Instalação - -Para instalar o componente Dotenv do KaririCode no seu projeto, execute o seguinte comando: - -```bash -composer require kariricode/dotenv -``` - -## Uso - -### Uso Básico - -1. Crie um arquivo `.env` no diretório raiz do seu projeto: - -```env -KARIRI_APP_ENV=develop -KARIRI_APP_NAME=KaririCode -KARIRI_PHP_VERSION=8.3 -KARIRI_PHP_PORT=9003 -KARIRI_APP_DEBUG=true -KARIRI_APP_URL=https://kariricode.com -KARIRI_MAIL_FROM_NAME="${KARIRI_APP_NAME}" -KARIRI_JSON_CONFIG={"key": "value", "nested": {"subkey": "subvalue"}} -KARIRI_ARRAY_CONFIG=["item1", "item2", "item with spaces"] -``` - -2. No arquivo de bootstrap da sua aplicação: - -```php -load(); - -// Agora você pode usar a função env() para acessar suas variáveis de ambiente -$appName = env('KARIRI_APP_NAME'); -$debug = env('KARIRI_APP_DEBUG'); -$jsonConfig = env('KARIRI_JSON_CONFIG'); -$arrayConfig = env('KARIRI_ARRAY_CONFIG'); -``` - -### Detecção e Conversão de Tipos - -O componente Dotenv do KaririCode detecta e converte automaticamente os seguintes tipos: - -- Strings -- Inteiros -- Floats -- Booleanos -- Valores nulos -- Arrays -- Objetos JSON - -Exemplo: - -```env -STRING_VAR=Hello World -INT_VAR=42 -FLOAT_VAR=3.14 -BOOL_VAR=true -NULL_VAR=null -ARRAY_VAR=["item1", "item2", "item3"] -JSON_VAR={"key": "value", "nested": {"subkey": "subvalue"}} -``` - -Quando acessadas usando a função `env()`, essas variáveis serão automaticamente convertidas para seus tipos PHP apropriados: - -```php -$stringVar = env('STRING_VAR'); // string: "Hello World" -$intVar = env('INT_VAR'); // inteiro: 42 -$floatVar = env('FLOAT_VAR'); // float: 3.14 -$boolVar = env('BOOL_VAR'); // booleano: true -$nullVar = env('NULL_VAR'); // null -$arrayVar = env('ARRAY_VAR'); // array: ["item1", "item2", "item3"] -$jsonVar = env('JSON_VAR'); // array: ["key" => "value", "nested" => ["subkey" => "subvalue"]] -``` - -Essa tipagem automática garante que você esteja trabalhando com os tipos corretos em sua aplicação, reduzindo erros relacionados a tipos e melhorando a confiabilidade geral do código. - -### Uso Avançado - -#### Detectores de Tipo Personalizados - -Crie detectores de tipo personalizados para lidar com formatos específicos: - -```php -use KaririCode\Dotenv\Type\Detector\AbstractTypeDetector; - -class CustomDetector extends AbstractTypeDetector -{ - public const PRIORITY = 100; - - public function detect(mixed $value): ?string - { - // Sua lógica de detecção aqui - // Retorne o tipo detectado como uma string, ou null se não detectado - } -} - -$dotenv->addTypeDetector(new CustomDetector()); -``` - -#### Conversores de Tipo Personalizados - -Crie conversores de tipo personalizados para lidar com tipos de dados específicos: - -```php -use KaririCode\Dotenv\Contract\Type\TypeCaster; - -class CustomCaster implements TypeCaster -{ - public function cast(mixed $value): mixed - { - // Sua lógica de conversão aqui - } -} - -$dotenv->addTypeCaster('custom_type', new CustomCaster()); -``` - -## Desenvolvimento e Testes - -Para fins de desenvolvimento e teste, este pacote utiliza Docker e Docker Compose para garantir consistência entre diferentes ambientes. Um Makefile é fornecido para conveniência. - -### Pré-requisitos - -- Docker -- Docker Compose -- Make (opcional, mas recomendado para facilitar a execução de comandos) - -### Configuração para Desenvolvimento - -1. Clone o repositório: - - ```bash - git clone https://github.com/KaririCode-Framework/kariricode-dotenv.git - cd kariricode-dotenv - ``` - -2. Configure o ambiente: - - ```bash - make setup-env - ``` - -3. Inicie os containers Docker: - - ```bash - make up - ``` - -4. Instale as dependências: - ```bash - make composer-install - ``` - -### Comandos Make Disponíveis - -- `make up`: Inicia todos os serviços em segundo plano -- `make down`: Para e remove todos os containers -- `make build`: Constrói as imagens Docker -- `make shell`: Acessa o shell do container PHP -- `make test`: Executa os testes -- `make coverage`: Executa a cobertura de testes com formatação visual -- `make cs-fix`: Executa o PHP CS Fixer para corrigir o estilo do código -- `make quality`: Executa todos os comandos de qualidade (cs-check, test, security-check) - -Para uma lista completa de comandos disponíveis, execute: - -```bash -make help -``` - -## Licença - -Este projeto está licenciado sob a Licença MIT - veja o arquivo [LICENSE](LICENSE) para mais detalhes. - -## Suporte e Comunidade - -- **Documentação**: [https://kariricode.org/docs/dotenv](https://kariricode.org/docs/dotenv) -- **Rastreador de Problemas**: [GitHub Issues](https://github.com/KaririCode-Framework/kariricode-dotenv/issues) -- **Comunidade**: [Comunidade KaririCode Club](https://kariricode.club) - -## Agradecimentos - -- A equipe do KaririCode Framework e contribuidores. -- Inspirado por outras bibliotecas populares de Dotenv para PHP. - ---- - -Construído com ❤️ pela equipe KaririCode. Capacitando desenvolvedores a criar aplicações PHP mais robustas e flexíveis. diff --git a/bin/kariricode-dotenv b/bin/kariricode-dotenv new file mode 100755 index 0000000..fa880e1 --- /dev/null +++ b/bin/kariricode-dotenv @@ -0,0 +1,510 @@ +#!/usr/bin/env php + [options] + * + * Commands: + * debug List all loaded variables with types and sources + * validate Validate .env against .env.example or .env.schema + * encrypt Encrypt plaintext values in a .env file + * decrypt Decrypt encrypted values in a .env file + * cache:dump Generate OPcache-friendly PHP cache + * cache:clear Remove the PHP cache file + * diff Compare two .env files + * example:generate Generate .env.example from .env (strips values) + * keygen Generate a new encryption key pair + */ + +namespace KaririCode\Dotenv\Console; + +// Autoload resolution: find vendor/autoload.php +(static function (): void { + $candidates = [ + __DIR__ . '/../vendor/autoload.php', + __DIR__ . '/../../../autoload.php', + ]; + + foreach ($candidates as $file) { + if (is_file($file)) { + require $file; + + return; + } + } + + fwrite(STDERR, "Cannot find vendor/autoload.php. Run: composer install\n"); + exit(1); +})(); + +use KaririCode\Dotenv\Cache\PhpFileCache; +use KaririCode\Dotenv\Dotenv; +use KaririCode\Dotenv\Security\Encryptor; +use KaririCode\Dotenv\Security\KeyPair; +use KaririCode\Dotenv\ValueObject\DotenvConfiguration; + +// ── Argument Parsing ────────────────────────────────────────────────── + +$args = array_slice($argv, 1); +$command = $args[0] ?? 'help'; +$options = parseOptions(array_slice($args, 1)); + +match ($command) { + 'debug' => commandDebug($options), + 'validate' => commandValidate($options), + 'encrypt' => commandEncrypt($options), + 'decrypt' => commandDecrypt($options), + 'cache:dump' => commandCacheDump($options), + 'cache:clear' => commandCacheClear($options), + 'diff' => commandDiff($options), + 'example:generate' => commandExampleGenerate($options), + 'keygen' => commandKeygen(), + 'help', '--help', '-h' => commandHelp(), + default => commandHelp("Unknown command: {$command}"), +}; + +// ── Commands ────────────────────────────────────────────────────────── + +function commandDebug(array $options): void +{ + $dir = $options['dir'] ?? getcwd(); + $dotenv = new Dotenv($dir); + + try { + $dotenv->safeLoad(); + } catch (\Throwable $e) { + stderr("Error loading .env: {$e->getMessage()}"); + exit(1); + } + + $debug = $dotenv->debug(); + + if ($debug === []) { + stdout("No variables loaded."); + + return; + } + + stdout(sprintf("%-30s %-10s %-10s %s", 'VARIABLE', 'TYPE', 'SOURCE', 'VALUE')); + stdout(str_repeat('─', 72)); + + foreach ($debug as $name => $info) { + $display = is_scalar($info['value']) + ? var_export($info['value'], true) + : gettype($info['value']); + + $override = $info['overridden'] ? ' (overridden)' : ''; + + stdout(sprintf( + "%-30s %-10s %-10s %s%s", + $name, + $info['type'], + $info['source'], + truncate($display, 40), + $override, + )); + } + + stdout("\nTotal: " . count($debug) . " variables."); +} + +function commandValidate(array $options): void +{ + $dir = $options['dir'] ?? getcwd(); + $schemaFile = $options['schema'] ?? null; + $exampleFile = $options['example'] ?? null; + + $dotenv = new Dotenv($dir); + + try { + $dotenv->safeLoad(); + } catch (\Throwable $e) { + stderr("Error loading .env: {$e->getMessage()}"); + exit(1); + } + + // Schema-based validation + if ($schemaFile !== null) { + $schemaPath = is_file($schemaFile) ? $schemaFile : $dir . DIRECTORY_SEPARATOR . $schemaFile; + + try { + $dotenv->loadWithSchema($schemaPath); + stdout("✓ All schema rules passed."); + } catch (\Throwable $e) { + stderr("✗ Schema validation failed:\n{$e->getMessage()}"); + exit(1); + } + + return; + } + + // Example-based validation (check all keys in .env.example exist in .env) + $examplePath = $exampleFile ?? $dir . DIRECTORY_SEPARATOR . '.env.example'; + + if (!is_file($examplePath)) { + stderr("No .env.example or --schema found. Nothing to validate."); + exit(1); + } + + $exampleContent = file_get_contents($examplePath); + $loaded = $dotenv->variables(); + $missing = []; + + foreach (explode("\n", $exampleContent) as $line) { + $line = trim($line); + + if ($line === '' || $line[0] === '#') { + continue; + } + + if (preg_match('/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=/', $line, $m)) { + $name = $m[1]; + + if (!isset($loaded[$name]) && !isset($_ENV[$name])) { + $missing[] = $name; + } + } + } + + if ($missing === []) { + stdout("✓ All variables from .env.example are defined."); + } else { + stderr("✗ Missing variables:\n " . implode("\n ", $missing)); + exit(1); + } +} + +function commandEncrypt(array $options): void +{ + $file = $options['_positional'][0] ?? '.env'; + $keyArg = $options['key'] ?? null; + + if (!is_file($file)) { + stderr("File not found: {$file}"); + exit(1); + } + + if ($keyArg === null) { + // Generate a new key + $keyPair = KeyPair::generate(); + $keyArg = $keyPair->privateKey; + stdout("Generated new encryption key:"); + stdout("DOTENV_PRIVATE_KEY={$keyPair->privateKey}"); + stdout("Key ID: {$keyPair->publicId}"); + stdout(""); + } + + $encryptor = new Encryptor($keyArg); + $content = file_get_contents($file); + $lines = explode("\n", $content); + $output = []; + + foreach ($lines as $line) { + $trimmed = trim($line); + + if ($trimmed === '' || $trimmed[0] === '#' || !str_contains($trimmed, '=')) { + $output[] = $line; + continue; + } + + $eqPos = strpos($trimmed, '='); + $key = substr($trimmed, 0, $eqPos); + $value = substr($trimmed, $eqPos + 1); + + // Strip quotes + if ((str_starts_with($value, '"') && str_ends_with($value, '"')) + || (str_starts_with($value, "'") && str_ends_with($value, "'"))) { + $value = substr($value, 1, -1); + } + + // Skip already encrypted + if (Encryptor::isEncrypted($value)) { + $output[] = $line; + continue; + } + + $encrypted = $encryptor->encrypt($value); + $output[] = "{$key}=\"{$encrypted}\""; + } + + file_put_contents($file, implode("\n", $output)); + stdout("✓ Encrypted values in {$file}"); + stdout("Store DOTENV_PRIVATE_KEY securely — it is required for decryption."); +} + +function commandDecrypt(array $options): void +{ + $file = $options['_positional'][0] ?? '.env'; + $keyArg = $options['key'] ?? $_SERVER['DOTENV_PRIVATE_KEY'] ?? $_ENV['DOTENV_PRIVATE_KEY'] ?? null; + + if (!is_file($file)) { + stderr("File not found: {$file}"); + exit(1); + } + + if ($keyArg === null) { + stderr("No decryption key provided. Use --key= or set DOTENV_PRIVATE_KEY."); + exit(1); + } + + $encryptor = new Encryptor($keyArg); + $content = file_get_contents($file); + $lines = explode("\n", $content); + $output = []; + + foreach ($lines as $line) { + $trimmed = trim($line); + + if ($trimmed === '' || $trimmed[0] === '#' || !str_contains($trimmed, '=')) { + $output[] = $line; + continue; + } + + $eqPos = strpos($trimmed, '='); + $key = substr($trimmed, 0, $eqPos); + $value = substr($trimmed, $eqPos + 1); + + // Strip quotes + $raw = $value; + + if ((str_starts_with($value, '"') && str_ends_with($value, '"')) + || (str_starts_with($value, "'") && str_ends_with($value, "'"))) { + $raw = substr($value, 1, -1); + } + + if (Encryptor::isEncrypted($raw)) { + $decrypted = $encryptor->decrypt($raw); + $output[] = "{$key}=\"{$decrypted}\""; + } else { + $output[] = $line; + } + } + + file_put_contents($file, implode("\n", $output)); + stdout("✓ Decrypted values in {$file}"); +} + +function commandCacheDump(array $options): void +{ + $dir = $options['dir'] ?? getcwd(); + $output = $options['output'] ?? $dir . DIRECTORY_SEPARATOR . '.env.cache.php'; + + $dotenv = new Dotenv($dir); + + try { + $dotenv->safeLoad(); + } catch (\Throwable $e) { + stderr("Error loading .env: {$e->getMessage()}"); + exit(1); + } + + $dotenv->dumpCache($output); + stdout("✓ Cache written to {$output}"); +} + +function commandCacheClear(array $options): void +{ + $dir = $options['dir'] ?? getcwd(); + $path = $options['path'] ?? $dir . DIRECTORY_SEPARATOR . '.env.cache.php'; + + (new PhpFileCache())->clear($path); + stdout("✓ Cache cleared: {$path}"); +} + +function commandDiff(array $options): void +{ + $files = $options['_positional'] ?? []; + + if (count($files) < 2) { + stderr("Usage: kariricode-dotenv diff "); + exit(1); + } + + $vars1 = parseEnvFileKeys($files[0]); + $vars2 = parseEnvFileKeys($files[1]); + + $onlyIn1 = array_diff_key($vars1, $vars2); + $onlyIn2 = array_diff_key($vars2, $vars1); + $different = []; + + foreach ($vars1 as $key => $val) { + if (isset($vars2[$key]) && $vars2[$key] !== $val) { + $different[$key] = [$val, $vars2[$key]]; + } + } + + $base1 = basename($files[0]); + $base2 = basename($files[1]); + + if ($onlyIn1 === [] && $onlyIn2 === [] && $different === []) { + stdout("✓ Files are identical."); + + return; + } + + if ($onlyIn1 !== []) { + stdout("Only in {$base1}:"); + + foreach ($onlyIn1 as $key => $val) { + stdout(" + {$key}"); + } + } + + if ($onlyIn2 !== []) { + stdout("Only in {$base2}:"); + + foreach ($onlyIn2 as $key => $val) { + stdout(" + {$key}"); + } + } + + if ($different !== []) { + stdout("Different values:"); + + foreach ($different as $key => [$v1, $v2]) { + stdout(" ~ {$key}"); + stdout(" {$base1}: {$v1}"); + stdout(" {$base2}: {$v2}"); + } + } +} + +function commandExampleGenerate(array $options): void +{ + $input = $options['_positional'][0] ?? '.env'; + $output = $options['output'] ?? '.env.example'; + + if (!is_file($input)) { + stderr("File not found: {$input}"); + exit(1); + } + + $content = file_get_contents($input); + $lines = explode("\n", $content); + $result = []; + + foreach ($lines as $line) { + $trimmed = trim($line); + + if ($trimmed === '' || $trimmed[0] === '#') { + $result[] = $line; + continue; + } + + if (preg_match('/^((?:export\s+)?[A-Za-z_][A-Za-z0-9_]*)=/', $trimmed, $m)) { + $result[] = $m[1] . '='; + } else { + $result[] = $line; + } + } + + file_put_contents($output, implode("\n", $result)); + stdout("✓ Generated {$output} from {$input}"); +} + +function commandKeygen(): void +{ + $keyPair = KeyPair::generate(); + stdout("Key ID: {$keyPair->publicId}"); + stdout("DOTENV_PRIVATE_KEY= {$keyPair->privateKey}"); + stdout(""); + stdout("Add to your server environment (NOT to .env files):"); + stdout(" export DOTENV_PRIVATE_KEY={$keyPair->privateKey}"); +} + +function commandHelp(string $error = ''): void +{ + if ($error !== '') { + stderr($error); + stderr(''); + } + + stdout('KaririCode Dotenv CLI v4.x'); + stdout(''); + stdout('Usage: kariricode-dotenv [options]'); + stdout(''); + stdout('Commands:'); + stdout(' debug List all loaded variables with types and sources'); + stdout(' validate Validate .env against .env.example or --schema=file'); + stdout(' encrypt [file] Encrypt plaintext values (--key= or auto-generate)'); + stdout(' decrypt [file] Decrypt encrypted values (--key= or DOTENV_PRIVATE_KEY)'); + stdout(' cache:dump Generate OPcache-friendly .env.cache.php'); + stdout(' cache:clear Remove the cache file'); + stdout(' diff Compare two .env files'); + stdout(' example:generate Generate .env.example from .env (strips values)'); + stdout(' keygen Generate a new encryption key pair'); + stdout(''); + stdout('Options:'); + stdout(' --dir= Project directory (default: cwd)'); + stdout(' --schema= Schema file for validation'); + stdout(' --example= Example file for validation'); + stdout(' --key= Encryption/decryption key (64-char hex)'); + stdout(' --output= Output file path'); + + exit($error !== '' ? 1 : 0); +} + +// ── Helpers ─────────────────────────────────────────────────────────── + +function parseOptions(array $args): array +{ + $options = ['_positional' => []]; + + foreach ($args as $arg) { + if (str_starts_with($arg, '--') && str_contains($arg, '=')) { + $eqPos = strpos($arg, '='); + $key = substr($arg, 2, $eqPos - 2); + $options[$key] = substr($arg, $eqPos + 1); + } elseif (str_starts_with($arg, '--')) { + $options[substr($arg, 2)] = true; + } else { + $options['_positional'][] = $arg; + } + } + + return $options; +} + +function parseEnvFileKeys(string $file): array +{ + if (!is_file($file)) { + stderr("File not found: {$file}"); + exit(1); + } + + $vars = []; + + foreach (explode("\n", file_get_contents($file)) as $line) { + $line = trim($line); + + if ($line === '' || $line[0] === '#') { + continue; + } + + if (preg_match('/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/', $line, $m)) { + $vars[$m[1]] = $m[2]; + } + } + + return $vars; +} + +function stdout(string $msg): void +{ + fwrite(STDOUT, $msg . "\n"); +} + +function stderr(string $msg): void +{ + fwrite(STDERR, $msg . "\n"); +} + +function truncate(string $str, int $max): string +{ + return strlen($str) > $max ? substr($str, 0, $max - 3) . '...' : $str; +} diff --git a/composer.json b/composer.json index ac1f093..a8afe3f 100644 --- a/composer.json +++ b/composer.json @@ -1,52 +1,65 @@ { - "name": "kariricode/dotenv", - "description": "A highly optimized, extensible, and reliable Dotenv component designed to load environment variables for PHP applications in the KaririCode Framework, ensuring secure and efficient configuration management.", - "keywords": [ - "dotenv", - "environment variables", - "configuration", - "PHP", - "Framework", - "secure", - "flexible", - "extensible", - "kaririCode" - ], - "homepage": "https://kariricode.org", - "type": "library", - "license": "MIT", - "authors": [ - { - "name": "Walmir Silva", - "email": "community@kariricode.org" - } - ], - "require": { - "php": "^8.3", - "kariricode/data-structure": "^1.0" - }, - "autoload": { - "psr-4": { - "KaririCode\\Dotenv\\": "src" + "name": "kariricode/dotenv", + "description": "The only PHP dotenv with auto type casting, AES-256-GCM encryption, OPcache caching, fluent validation DSL, environment-aware loading, and CLI tooling — zero dependencies, PHP 8.4+, ARFA 1.3.", + "type": "library", + "license": "MIT", + "keywords": [ + "framework", + "php", + "configuration", + "dotenv", + "environment-variables", + "env", + "kariricode", + "type-safe", + "immutable", + "encrypted", + "validation", + "cache", + "php84" + ], + "authors": [ + { + "name": "Walmir Silva", + "email": "community@kariricode.org" + } + ], + "homepage": "https://kariricode.org", + "bin": [ + "bin/kariricode-dotenv" + ], + "require": { + "php": "^8.4" }, - "files": [ - "src/functions.php" - ] - }, - "autoload-dev": { - "psr-4": { - "KaririCode\\Dotenv\\Tests\\": "tests" - } - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.51", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^11.0", - "squizlabs/php_codesniffer": "^3.9", - "enlightn/security-checker": "^2.0" - }, - "support": { - "issues": "https://github.com/KaririCode-Framework/kariricode-dotenv/issues", - "source": "https://github.com/KaririCode-Framework/kariricode-dotenv" - } + "suggest": { + "ext-openssl": "Required for encrypted .env values (AES-256-GCM)" + }, + "autoload": { + "psr-4": { + "KaririCode\\Dotenv\\": "src/" + }, + "files": [ + "src/env.php" + ] + }, + "autoload-dev": { + "psr-4": { + "KaririCode\\Dotenv\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "analyse": "phpstan analyse", + "cs-fix": "php-cs-fixer fix" + }, + "config": { + "sort-packages": true, + "optimize-autoloader": true + }, + "support": { + "issues": "https://github.com/KaririCode-Framework/kariricode-dotenv/issues", + "source": "https://github.com/KaririCode-Framework/kariricode-dotenv" + }, + "minimum-stability": "stable", + "prefer-stable": true } diff --git a/composer.lock b/composer.lock index d992727..6a23a46 100644 --- a/composer.lock +++ b/composer.lock @@ -4,5055 +4,17 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ac88ff6156fd02e4b855fd84934f350c", - "packages": [ - { - "name": "kariricode/contract", - "version": "v2.6.3", - "source": { - "type": "git", - "url": "https://github.com/KaririCode-Framework/kariricode-contract.git", - "reference": "5d98b009c7c5c20dd63b4440405ac81f93544e7d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/KaririCode-Framework/kariricode-contract/zipball/5d98b009c7c5c20dd63b4440405ac81f93544e7d", - "reference": "5d98b009c7c5c20dd63b4440405ac81f93544e7d", - "shasum": "" - }, - "require": { - "php": "^8.3" - }, - "require-dev": { - "enlightn/security-checker": "^2.0", - "friendsofphp/php-cs-fixer": "^3.58", - "mockery/mockery": "^1.6", - "nunomaduro/phpinsights": "^2.11", - "phpunit/phpunit": "^10.5", - "squizlabs/php_codesniffer": "^3.10" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "KaririCode\\Contract\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Walmir Silva", - "email": "walmir.silva@kariricode.org" - } - ], - "description": "Central repository for interface definitions in the KaririCode Framework. Implements interface code in PHP for specified namespaces, adhering to PSR standards and leveraging modern PHP features.", - "homepage": "https://kariricode.org/", - "keywords": [ - "PSRs", - "contract", - "framework", - "interface", - "kariri", - "php" - ], - "support": { - "issues": "https://github.com/KaririCode-Framework/kariricode-contract/issues", - "source": "https://github.com/KaririCode-Framework/kariricode-contract" - }, - "time": "2024-10-10T21:05:49+00:00" - }, - { - "name": "kariricode/data-structure", - "version": "v1.1.0", - "source": { - "type": "git", - "url": "https://github.com/KaririCode-Framework/kariricode-data-structure.git", - "reference": "91c9e7ef36143b5e3d449f1a132169580945a4c8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/KaririCode-Framework/kariricode-data-structure/zipball/91c9e7ef36143b5e3d449f1a132169580945a4c8", - "reference": "91c9e7ef36143b5e3d449f1a132169580945a4c8", - "shasum": "" - }, - "require": { - "kariricode/contract": "^2.0", - "php": "^8.3" - }, - "require-dev": { - "enlightn/security-checker": "^2.0", - "friendsofphp/php-cs-fixer": "^3.58", - "mockery/mockery": "^1.6", - "nunomaduro/phpinsights": "^2.11", - "phpunit/phpunit": "^10.5", - "squizlabs/php_codesniffer": "^3.10" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "KaririCode\\DataStructure\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Walmir Silva", - "email": "walmir.silva@kariricode.org" - } - ], - "description": "The KaririCode DataStructure component offers advanced PHP data structures, including lists, stacks, queues, maps, and sets. It features efficient, strongly-typed, object-oriented implementations like ArrayList, LinkedList, BinaryHeap, and TreeMap.", - "homepage": "https://kariricode.org/", - "keywords": [ - "KaririCode", - "LinkedList", - "OOP", - "algorithms", - "arraylist", - "binary heap", - "collections", - "data structures", - "dynamic array", - "heap", - "php", - "queue", - "red-black tree", - "set", - "stack", - "strong typing", - "treemap" - ], - "support": { - "issues": "https://github.com/KaririCode-Framework/kariricode-data-structure/issues", - "source": "https://github.com/KaririCode-Framework/kariricode-data-structure" - }, - "time": "2024-10-10T22:37:23+00:00" - } - ], - "packages-dev": [ - { - "name": "clue/ndjson-react", - "version": "v1.3.0", - "source": { - "type": "git", - "url": "https://github.com/clue/reactphp-ndjson.git", - "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", - "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", - "shasum": "" - }, - "require": { - "php": ">=5.3", - "react/stream": "^1.2" - }, - "require-dev": { - "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", - "react/event-loop": "^1.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Clue\\React\\NDJson\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Christian Lück", - "email": "christian@clue.engineering" - } - ], - "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", - "homepage": "https://github.com/clue/reactphp-ndjson", - "keywords": [ - "NDJSON", - "json", - "jsonlines", - "newline", - "reactphp", - "streaming" - ], - "support": { - "issues": "https://github.com/clue/reactphp-ndjson/issues", - "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" - }, - "funding": [ - { - "url": "https://clue.engineering/support", - "type": "custom" - }, - { - "url": "https://github.com/clue", - "type": "github" - } - ], - "time": "2022-12-23T10:58:28+00:00" - }, - { - "name": "composer/pcre", - "version": "3.3.1", - "source": { - "type": "git", - "url": "https://github.com/composer/pcre.git", - "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/63aaeac21d7e775ff9bc9d45021e1745c97521c4", - "reference": "63aaeac21d7e775ff9bc9d45021e1745c97521c4", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<1.11.10" - }, - "require-dev": { - "phpstan/phpstan": "^1.11.10", - "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "^8 || ^9" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - }, - "phpstan": { - "includes": [ - "extension.neon" - ] - } - }, - "autoload": { - "psr-4": { - "Composer\\Pcre\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "PCRE wrapping library that offers type-safe preg_* replacements.", - "keywords": [ - "PCRE", - "preg", - "regex", - "regular expression" - ], - "support": { - "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.1" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-08-27T18:44:43+00:00" - }, - { - "name": "composer/semver", - "version": "3.4.3", - "source": { - "type": "git", - "url": "https://github.com/composer/semver.git", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", - "shasum": "" - }, - "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.11", - "symfony/phpunit-bridge": "^3 || ^7" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Semver\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "http://www.naderman.de" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - }, - { - "name": "Rob Bast", - "email": "rob.bast@gmail.com", - "homepage": "http://robbast.nl" - } - ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", - "keywords": [ - "semantic", - "semver", - "validation", - "versioning" - ], - "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.3" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-09-19T14:15:21+00:00" - }, - { - "name": "composer/xdebug-handler", - "version": "3.0.5", - "source": { - "type": "git", - "url": "https://github.com/composer/xdebug-handler.git", - "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", - "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", - "shasum": "" - }, - "require": { - "composer/pcre": "^1 || ^2 || ^3", - "php": "^7.2.5 || ^8.0", - "psr/log": "^1 || ^2 || ^3" - }, - "require-dev": { - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Composer\\XdebugHandler\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "John Stevenson", - "email": "john-stevenson@blueyonder.co.uk" - } - ], - "description": "Restarts a process without Xdebug.", - "keywords": [ - "Xdebug", - "performance" - ], - "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2024-05-06T16:37:16+00:00" - }, - { - "name": "enlightn/security-checker", - "version": "v2.0.0", - "source": { - "type": "git", - "url": "https://github.com/enlightn/security-checker.git", - "reference": "d495ab07639388c7c770c5223aa0d42fee1d2604" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/enlightn/security-checker/zipball/d495ab07639388c7c770c5223aa0d42fee1d2604", - "reference": "d495ab07639388c7c770c5223aa0d42fee1d2604", - "shasum": "" - }, - "require": { - "ext-json": "*", - "guzzlehttp/guzzle": "^6.3|^7.0", - "php": ">=8.2", - "symfony/console": "^7", - "symfony/finder": "^3|^4|^5|^6|^7", - "symfony/process": "^3.4|^4|^5|^6|^7", - "symfony/yaml": "^3.4|^4|^5|^6|^7" - }, - "require-dev": { - "ext-zip": "*", - "friendsofphp/php-cs-fixer": "^2.18|^3.0", - "phpunit/phpunit": "^5.5|^6|^7|^8|^9" - }, - "bin": [ - "security-checker" - ], - "type": "library", - "autoload": { - "psr-4": { - "Enlightn\\SecurityChecker\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Paras Malhotra", - "email": "paras@laravel-enlightn.com" - }, - { - "name": "Miguel Piedrafita", - "email": "soy@miguelpiedrafita.com" - } - ], - "description": "A PHP dependency vulnerabilities scanner based on the Security Advisories Database.", - "keywords": [ - "package", - "php", - "scanner", - "security", - "security advisories", - "vulnerability scanner" - ], - "support": { - "issues": "https://github.com/enlightn/security-checker/issues", - "source": "https://github.com/enlightn/security-checker/tree/v2.0.0" - }, - "time": "2023-12-10T07:17:09+00:00" - }, - { - "name": "evenement/evenement", - "version": "v3.0.2", - "source": { - "type": "git", - "url": "https://github.com/igorw/evenement.git", - "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", - "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", - "shasum": "" - }, - "require": { - "php": ">=7.0" - }, - "require-dev": { - "phpunit/phpunit": "^9 || ^6" - }, - "type": "library", - "autoload": { - "psr-4": { - "Evenement\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - } - ], - "description": "Événement is a very simple event dispatching library for PHP", - "keywords": [ - "event-dispatcher", - "event-emitter" - ], - "support": { - "issues": "https://github.com/igorw/evenement/issues", - "source": "https://github.com/igorw/evenement/tree/v3.0.2" - }, - "time": "2023-08-08T05:53:35+00:00" - }, - { - "name": "fidry/cpu-core-counter", - "version": "1.2.0", - "source": { - "type": "git", - "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "8520451a140d3f46ac33042715115e290cf5785f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/8520451a140d3f46ac33042715115e290cf5785f", - "reference": "8520451a140d3f46ac33042715115e290cf5785f", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "require-dev": { - "fidry/makefile": "^0.2.0", - "fidry/php-cs-fixer-config": "^1.1.2", - "phpstan/extension-installer": "^1.2.0", - "phpstan/phpstan": "^1.9.2", - "phpstan/phpstan-deprecation-rules": "^1.0.0", - "phpstan/phpstan-phpunit": "^1.2.2", - "phpstan/phpstan-strict-rules": "^1.4.4", - "phpunit/phpunit": "^8.5.31 || ^9.5.26", - "webmozarts/strict-phpunit": "^7.5" - }, - "type": "library", - "autoload": { - "psr-4": { - "Fidry\\CpuCoreCounter\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Théo FIDRY", - "email": "theo.fidry@gmail.com" - } - ], - "description": "Tiny utility to get the number of CPU cores.", - "keywords": [ - "CPU", - "core" - ], - "support": { - "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.2.0" - }, - "funding": [ - { - "url": "https://github.com/theofidry", - "type": "github" - } - ], - "time": "2024-08-06T10:04:20+00:00" - }, - { - "name": "friendsofphp/php-cs-fixer", - "version": "v3.64.0", - "source": { - "type": "git", - "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "58dd9c931c785a79739310aef5178928305ffa67" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/58dd9c931c785a79739310aef5178928305ffa67", - "reference": "58dd9c931c785a79739310aef5178928305ffa67", - "shasum": "" - }, - "require": { - "clue/ndjson-react": "^1.0", - "composer/semver": "^3.4", - "composer/xdebug-handler": "^3.0.3", - "ext-filter": "*", - "ext-json": "*", - "ext-tokenizer": "*", - "fidry/cpu-core-counter": "^1.0", - "php": "^7.4 || ^8.0", - "react/child-process": "^0.6.5", - "react/event-loop": "^1.0", - "react/promise": "^2.0 || ^3.0", - "react/socket": "^1.0", - "react/stream": "^1.0", - "sebastian/diff": "^4.0 || ^5.0 || ^6.0", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0", - "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", - "symfony/finder": "^5.4 || ^6.0 || ^7.0", - "symfony/options-resolver": "^5.4 || ^6.0 || ^7.0", - "symfony/polyfill-mbstring": "^1.28", - "symfony/polyfill-php80": "^1.28", - "symfony/polyfill-php81": "^1.28", - "symfony/process": "^5.4 || ^6.0 || ^7.0", - "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0" - }, - "require-dev": { - "facile-it/paraunit": "^1.3 || ^2.3", - "infection/infection": "^0.29.5", - "justinrainbow/json-schema": "^5.2", - "keradus/cli-executor": "^2.1", - "mikey179/vfsstream": "^1.6.11", - "php-coveralls/php-coveralls": "^2.7", - "php-cs-fixer/accessible-object": "^1.1", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.5", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.5", - "phpunit/phpunit": "^9.6.19 || ^10.5.21 || ^11.2", - "symfony/var-dumper": "^5.4 || ^6.0 || ^7.0", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0" - }, - "suggest": { - "ext-dom": "For handling output formats in XML", - "ext-mbstring": "For handling non-UTF8 characters." - }, - "bin": [ - "php-cs-fixer" - ], - "type": "application", - "autoload": { - "psr-4": { - "PhpCsFixer\\": "src/" - }, - "exclude-from-classmap": [ - "src/Fixer/Internal/*" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Dariusz Rumiński", - "email": "dariusz.ruminski@gmail.com" - } - ], - "description": "A tool to automatically fix PHP code style", - "keywords": [ - "Static code analysis", - "fixer", - "standards", - "static analysis" - ], - "support": { - "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.64.0" - }, - "funding": [ - { - "url": "https://github.com/keradus", - "type": "github" - } - ], - "time": "2024-08-30T23:09:38+00:00" - }, - { - "name": "guzzlehttp/guzzle", - "version": "7.9.2", - "source": { - "type": "git", - "url": "https://github.com/guzzle/guzzle.git", - "reference": "d281ed313b989f213357e3be1a179f02196ac99b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b", - "reference": "d281ed313b989f213357e3be1a179f02196ac99b", - "shasum": "" - }, - "require": { - "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0.3", - "guzzlehttp/psr7": "^2.7.0", - "php": "^7.2.5 || ^8.0", - "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" - }, - "provide": { - "psr/http-client-implementation": "1.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "ext-curl": "*", - "guzzle/client-integration-tests": "3.0.2", - "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", - "psr/log": "^1.1 || ^2.0 || ^3.0" - }, - "suggest": { - "ext-curl": "Required for CURL handler support", - "ext-intl": "Required for Internationalized Domain Name (IDN) support", - "psr/log": "Required for using the Log middleware" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "GuzzleHttp\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Jeremy Lindblom", - "email": "jeremeamia@gmail.com", - "homepage": "https://github.com/jeremeamia" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - } - ], - "description": "Guzzle is a PHP HTTP client library", - "keywords": [ - "client", - "curl", - "framework", - "http", - "http client", - "psr-18", - "psr-7", - "rest", - "web service" - ], - "support": { - "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.9.2" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", - "type": "tidelift" - } - ], - "time": "2024-07-24T11:22:20+00:00" - }, - { - "name": "guzzlehttp/promises", - "version": "2.0.3", - "source": { - "type": "git", - "url": "https://github.com/guzzle/promises.git", - "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8", - "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8", - "shasum": "" - }, - "require": { - "php": "^7.2.5 || ^8.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Promise\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - } - ], - "description": "Guzzle promises library", - "keywords": [ - "promise" - ], - "support": { - "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.0.3" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", - "type": "tidelift" - } - ], - "time": "2024-07-18T10:29:17+00:00" - }, - { - "name": "guzzlehttp/psr7", - "version": "2.7.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/psr7.git", - "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201", - "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201", - "shasum": "" - }, - "require": { - "php": "^7.2.5 || ^8.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" - }, - "provide": { - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" - }, - "suggest": { - "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Psr7\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://sagikazarmark.hu" - } - ], - "description": "PSR-7 message implementation that also provides common utility methods", - "keywords": [ - "http", - "message", - "psr-7", - "request", - "response", - "stream", - "uri", - "url" - ], - "support": { - "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.7.0" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", - "type": "tidelift" - } - ], - "time": "2024-07-18T11:15:46+00:00" - }, - { - "name": "myclabs/deep-copy", - "version": "1.12.0", - "source": { - "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", - "reference": "3a6b9a42cd8f8771bd4295d13e1423fa7f3d942c", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" - }, - "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpspec/prophecy": "^1.10", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" - }, - "type": "library", - "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" - ], - "psr-4": { - "DeepCopy\\": "src/DeepCopy/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Create deep copies (clones) of your objects", - "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" - ], - "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.0" - }, - "funding": [ - { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", - "type": "tidelift" - } - ], - "time": "2024-06-12T14:39:25+00:00" - }, - { - "name": "nikic/php-parser", - "version": "v5.3.1", - "source": { - "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-json": "*", - "ext-tokenizer": "*", - "php": ">=7.4" - }, - "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^9.0" - }, - "bin": [ - "bin/php-parse" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0-dev" - } - }, - "autoload": { - "psr-4": { - "PhpParser\\": "lib/PhpParser" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Nikita Popov" - } - ], - "description": "A PHP parser written in PHP", - "keywords": [ - "parser", - "php" - ], - "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" - }, - "time": "2024-10-08T18:51:32+00:00" - }, - { - "name": "phar-io/manifest", - "version": "2.0.4", - "source": { - "type": "git", - "url": "https://github.com/phar-io/manifest.git", - "reference": "54750ef60c58e43759730615a392c31c80e23176" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", - "reference": "54750ef60c58e43759730615a392c31c80e23176", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-phar": "*", - "ext-xmlwriter": "*", - "phar-io/version": "^3.0.1", - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" - } - ], - "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", - "support": { - "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.4" - }, - "funding": [ - { - "url": "https://github.com/theseer", - "type": "github" - } - ], - "time": "2024-03-03T12:33:53+00:00" - }, - { - "name": "phar-io/version", - "version": "3.2.1", - "source": { - "type": "git", - "url": "https://github.com/phar-io/version.git", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" - } - ], - "description": "Library for handling version information and constraints", - "support": { - "issues": "https://github.com/phar-io/version/issues", - "source": "https://github.com/phar-io/version/tree/3.2.1" - }, - "time": "2022-02-21T01:04:05+00:00" - }, - { - "name": "phpstan/phpstan", - "version": "1.12.6", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "dc4d2f145a88ea7141ae698effd64d9df46527ae" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc4d2f145a88ea7141ae698effd64d9df46527ae", - "reference": "dc4d2f145a88ea7141ae698effd64d9df46527ae", - "shasum": "" - }, - "require": { - "php": "^7.2|^8.0" - }, - "conflict": { - "phpstan/phpstan-shim": "*" - }, - "bin": [ - "phpstan", - "phpstan.phar" - ], - "type": "library", - "autoload": { - "files": [ - "bootstrap.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHPStan - PHP Static Analysis Tool", - "keywords": [ - "dev", - "static analysis" - ], - "support": { - "docs": "https://phpstan.org/user-guide/getting-started", - "forum": "https://github.com/phpstan/phpstan/discussions", - "issues": "https://github.com/phpstan/phpstan/issues", - "security": "https://github.com/phpstan/phpstan/security/policy", - "source": "https://github.com/phpstan/phpstan-src" - }, - "funding": [ - { - "url": "https://github.com/ondrejmirtes", - "type": "github" - }, - { - "url": "https://github.com/phpstan", - "type": "github" - } - ], - "time": "2024-10-06T15:03:59+00:00" - }, - { - "name": "phpunit/php-code-coverage", - "version": "11.0.7", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "f7f08030e8811582cc459871d28d6f5a1a4d35ca" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f7f08030e8811582cc459871d28d6f5a1a4d35ca", - "reference": "f7f08030e8811582cc459871d28d6f5a1a4d35ca", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-xmlwriter": "*", - "nikic/php-parser": "^5.3.1", - "php": ">=8.2", - "phpunit/php-file-iterator": "^5.1.0", - "phpunit/php-text-template": "^4.0.1", - "sebastian/code-unit-reverse-lookup": "^4.0.1", - "sebastian/complexity": "^4.0.1", - "sebastian/environment": "^7.2.0", - "sebastian/lines-of-code": "^3.0.1", - "sebastian/version": "^5.0.2", - "theseer/tokenizer": "^1.2.3" - }, - "require-dev": { - "phpunit/phpunit": "^11.4.1" - }, - "suggest": { - "ext-pcov": "PHP extension that provides line coverage", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "11.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", - "homepage": "https://github.com/sebastianbergmann/php-code-coverage", - "keywords": [ - "coverage", - "testing", - "xunit" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.7" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-10-09T06:21:38+00:00" - }, - { - "name": "phpunit/php-file-iterator", - "version": "5.1.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "5.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "FilterIterator implementation that filters files based on a list of suffixes.", - "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", - "keywords": [ - "filesystem", - "iterator" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-08-27T05:02:59+00:00" - }, - { - "name": "phpunit/php-invoker", - "version": "5.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", - "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "ext-pcntl": "*", - "phpunit/phpunit": "^11.0" - }, - "suggest": { - "ext-pcntl": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "5.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Invoke callables with a timeout", - "homepage": "https://github.com/sebastianbergmann/php-invoker/", - "keywords": [ - "process" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T05:07:44+00:00" - }, - { - "name": "phpunit/php-text-template", - "version": "4.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", - "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Simple template engine.", - "homepage": "https://github.com/sebastianbergmann/php-text-template/", - "keywords": [ - "template" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T05:08:43+00:00" - }, - { - "name": "phpunit/php-timer", - "version": "7.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", - "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "7.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Utility class for timing", - "homepage": "https://github.com/sebastianbergmann/php-timer/", - "keywords": [ - "timer" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "security": "https://github.com/sebastianbergmann/php-timer/security/policy", - "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T05:09:35+00:00" - }, - { - "name": "phpunit/phpunit", - "version": "11.4.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "7875627f15f4da7e7f0823d1f323f7295a77334e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7875627f15f4da7e7f0823d1f323f7295a77334e", - "reference": "7875627f15f4da7e7f0823d1f323f7295a77334e", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "ext-xml": "*", - "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.0", - "phar-io/manifest": "^2.0.4", - "phar-io/version": "^3.2.1", - "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.6", - "phpunit/php-file-iterator": "^5.1.0", - "phpunit/php-invoker": "^5.0.1", - "phpunit/php-text-template": "^4.0.1", - "phpunit/php-timer": "^7.0.1", - "sebastian/cli-parser": "^3.0.2", - "sebastian/code-unit": "^3.0.1", - "sebastian/comparator": "^6.1.0", - "sebastian/diff": "^6.0.2", - "sebastian/environment": "^7.2.0", - "sebastian/exporter": "^6.1.3", - "sebastian/global-state": "^7.0.2", - "sebastian/object-enumerator": "^6.0.1", - "sebastian/type": "^5.1.0", - "sebastian/version": "^5.0.1" - }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files" - }, - "bin": [ - "phpunit" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "11.4-dev" - } - }, - "autoload": { - "files": [ - "src/Framework/Assert/Functions.php" - ], - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", - "keywords": [ - "phpunit", - "testing", - "xunit" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.4.1" - }, - "funding": [ - { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" - } - ], - "time": "2024-10-08T15:38:37+00:00" - }, - { - "name": "psr/container", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "shasum": "" - }, - "require": { - "php": ">=7.4.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" - }, - "time": "2021-11-05T16:47:00+00:00" - }, - { - "name": "psr/event-dispatcher", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", - "shasum": "" - }, - "require": { - "php": ">=7.2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\EventDispatcher\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" - } - ], - "description": "Standard interfaces for event handling.", - "keywords": [ - "events", - "psr", - "psr-14" - ], - "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" - }, - "time": "2019-01-08T18:20:26+00:00" - }, - { - "name": "psr/http-client", - "version": "1.0.3", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-client.git", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", - "shasum": "" - }, - "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Client\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP clients", - "homepage": "https://github.com/php-fig/http-client", - "keywords": [ - "http", - "http-client", - "psr", - "psr-18" - ], - "support": { - "source": "https://github.com/php-fig/http-client" - }, - "time": "2023-09-23T14:17:50+00:00" - }, - { - "name": "psr/http-factory", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "shasum": "" - }, - "require": { - "php": ">=7.1", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", - "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-factory" - }, - "time": "2024-04-15T12:06:14+00:00" - }, - { - "name": "psr/http-message", - "version": "2.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", - "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" - }, - "time": "2023-04-04T09:54:51+00:00" - }, - { - "name": "psr/log", - "version": "3.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" - }, - "time": "2024-09-11T13:17:53+00:00" - }, - { - "name": "ralouphie/getallheaders", - "version": "3.0.3", - "source": { - "type": "git", - "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "120b605dfeb996808c31b6477290a714d356e822" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", - "reference": "120b605dfeb996808c31b6477290a714d356e822", - "shasum": "" - }, - "require": { - "php": ">=5.6" - }, - "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^5 || ^6.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/getallheaders.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ralph Khattar", - "email": "ralph.khattar@gmail.com" - } - ], - "description": "A polyfill for getallheaders.", - "support": { - "issues": "https://github.com/ralouphie/getallheaders/issues", - "source": "https://github.com/ralouphie/getallheaders/tree/develop" - }, - "time": "2019-03-08T08:55:37+00:00" - }, - { - "name": "react/cache", - "version": "v1.2.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/cache.git", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", - "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", - "shasum": "" - }, - "require": { - "php": ">=5.3.0", - "react/promise": "^3.0 || ^2.0 || ^1.1" - }, - "require-dev": { - "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" - }, - "type": "library", - "autoload": { - "psr-4": { - "React\\Cache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "Async, Promise-based cache interface for ReactPHP", - "keywords": [ - "cache", - "caching", - "promise", - "reactphp" - ], - "support": { - "issues": "https://github.com/reactphp/cache/issues", - "source": "https://github.com/reactphp/cache/tree/v1.2.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2022-11-30T15:59:55+00:00" - }, - { - "name": "react/child-process", - "version": "v0.6.5", - "source": { - "type": "git", - "url": "https://github.com/reactphp/child-process.git", - "reference": "e71eb1aa55f057c7a4a0d08d06b0b0a484bead43" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/child-process/zipball/e71eb1aa55f057c7a4a0d08d06b0b0a484bead43", - "reference": "e71eb1aa55f057c7a4a0d08d06b0b0a484bead43", - "shasum": "" - }, - "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.0", - "react/event-loop": "^1.2", - "react/stream": "^1.2" - }, - "require-dev": { - "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35", - "react/socket": "^1.8", - "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "React\\ChildProcess\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "Event-driven library for executing child processes with ReactPHP.", - "keywords": [ - "event-driven", - "process", - "reactphp" - ], - "support": { - "issues": "https://github.com/reactphp/child-process/issues", - "source": "https://github.com/reactphp/child-process/tree/v0.6.5" - }, - "funding": [ - { - "url": "https://github.com/WyriHaximus", - "type": "github" - }, - { - "url": "https://github.com/clue", - "type": "github" - } - ], - "time": "2022-09-16T13:41:56+00:00" - }, - { - "name": "react/dns", - "version": "v1.13.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/dns.git", - "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/dns/zipball/eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", - "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", - "shasum": "" - }, - "require": { - "php": ">=5.3.0", - "react/cache": "^1.0 || ^0.6 || ^0.5", - "react/event-loop": "^1.2", - "react/promise": "^3.2 || ^2.7 || ^1.2.1" - }, - "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3 || ^2", - "react/promise-timer": "^1.11" - }, - "type": "library", - "autoload": { - "psr-4": { - "React\\Dns\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "Async DNS resolver for ReactPHP", - "keywords": [ - "async", - "dns", - "dns-resolver", - "reactphp" - ], - "support": { - "issues": "https://github.com/reactphp/dns/issues", - "source": "https://github.com/reactphp/dns/tree/v1.13.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2024-06-13T14:18:03+00:00" - }, - { - "name": "react/event-loop", - "version": "v1.5.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/event-loop.git", - "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", - "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", - "shasum": "" - }, - "require": { - "php": ">=5.3.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" - }, - "suggest": { - "ext-pcntl": "For signal handling support when using the StreamSelectLoop" - }, - "type": "library", - "autoload": { - "psr-4": { - "React\\EventLoop\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", - "keywords": [ - "asynchronous", - "event-loop" - ], - "support": { - "issues": "https://github.com/reactphp/event-loop/issues", - "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2023-11-13T13:48:05+00:00" - }, - { - "name": "react/promise", - "version": "v3.2.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "8a164643313c71354582dc850b42b33fa12a4b63" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/8a164643313c71354582dc850b42b33fa12a4b63", - "reference": "8a164643313c71354582dc850b42b33fa12a4b63", - "shasum": "" - }, - "require": { - "php": ">=7.1.0" - }, - "require-dev": { - "phpstan/phpstan": "1.10.39 || 1.4.10", - "phpunit/phpunit": "^9.6 || ^7.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "React\\Promise\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", - "keywords": [ - "promise", - "promises" - ], - "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.2.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2024-05-24T10:39:05+00:00" - }, - { - "name": "react/socket", - "version": "v1.16.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/socket.git", - "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/socket/zipball/23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", - "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", - "shasum": "" - }, - "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.0", - "react/dns": "^1.13", - "react/event-loop": "^1.2", - "react/promise": "^3.2 || ^2.6 || ^1.2.1", - "react/stream": "^1.4" - }, - "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", - "react/async": "^4.3 || ^3.3 || ^2", - "react/promise-stream": "^1.4", - "react/promise-timer": "^1.11" - }, - "type": "library", - "autoload": { - "psr-4": { - "React\\Socket\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", - "keywords": [ - "Connection", - "Socket", - "async", - "reactphp", - "stream" - ], - "support": { - "issues": "https://github.com/reactphp/socket/issues", - "source": "https://github.com/reactphp/socket/tree/v1.16.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2024-07-26T10:38:09+00:00" - }, - { - "name": "react/stream", - "version": "v1.4.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/stream.git", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", - "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", - "shasum": "" - }, - "require": { - "evenement/evenement": "^3.0 || ^2.0 || ^1.0", - "php": ">=5.3.8", - "react/event-loop": "^1.2" - }, - "require-dev": { - "clue/stream-filter": "~1.2", - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" - }, - "type": "library", - "autoload": { - "psr-4": { - "React\\Stream\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", - "keywords": [ - "event-driven", - "io", - "non-blocking", - "pipe", - "reactphp", - "readable", - "stream", - "writable" - ], - "support": { - "issues": "https://github.com/reactphp/stream/issues", - "source": "https://github.com/reactphp/stream/tree/v1.4.0" - }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2024-06-11T12:45:25+00:00" - }, - { - "name": "sebastian/cli-parser", - "version": "3.0.2", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", - "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library for parsing CLI options", - "homepage": "https://github.com/sebastianbergmann/cli-parser", - "support": { - "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T04:41:36+00:00" - }, - { - "name": "sebastian/code-unit", - "version": "3.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "6bb7d09d6623567178cf54126afa9c2310114268" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/6bb7d09d6623567178cf54126afa9c2310114268", - "reference": "6bb7d09d6623567178cf54126afa9c2310114268", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "security": "https://github.com/sebastianbergmann/code-unit/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T04:44:28+00:00" - }, - { - "name": "sebastian/code-unit-reverse-lookup", - "version": "4.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "183a9b2632194febd219bb9246eee421dad8d45e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", - "reference": "183a9b2632194febd219bb9246eee421dad8d45e", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T04:45:54+00:00" - }, - { - "name": "sebastian/comparator", - "version": "6.1.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "fa37b9e2ca618cb051d71b60120952ee8ca8b03d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa37b9e2ca618cb051d71b60120952ee8ca8b03d", - "reference": "fa37b9e2ca618cb051d71b60120952ee8ca8b03d", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/diff": "^6.0", - "sebastian/exporter": "^6.0" - }, - "require-dev": { - "phpunit/phpunit": "^11.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.1-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" - } - ], - "description": "Provides the functionality to compare PHP values for equality", - "homepage": "https://github.com/sebastianbergmann/comparator", - "keywords": [ - "comparator", - "compare", - "equality" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/comparator/issues", - "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.1.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-09-11T15:42:56+00:00" - }, - { - "name": "sebastian/complexity", - "version": "4.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", - "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", - "shasum": "" - }, - "require": { - "nikic/php-parser": "^5.0", - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library for calculating the complexity of PHP code units", - "homepage": "https://github.com/sebastianbergmann/complexity", - "support": { - "issues": "https://github.com/sebastianbergmann/complexity/issues", - "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T04:49:50+00:00" - }, - { - "name": "sebastian/diff", - "version": "6.0.2", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", - "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0", - "symfony/process": "^4.2 || ^5" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" - } - ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", - "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/diff/issues", - "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T04:53:05+00:00" - }, - { - "name": "sebastian/environment", - "version": "7.2.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "suggest": { - "ext-posix": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "7.2-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "https://github.com/sebastianbergmann/environment", - "keywords": [ - "Xdebug", - "environment", - "hhvm" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/environment/issues", - "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T04:54:44+00:00" - }, - { - "name": "sebastian/exporter", - "version": "6.1.3", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "c414673eee9a8f9d51bbf8d61fc9e3ef1e85b20e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c414673eee9a8f9d51bbf8d61fc9e3ef1e85b20e", - "reference": "c414673eee9a8f9d51bbf8d61fc9e3ef1e85b20e", - "shasum": "" - }, - "require": { - "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/recursion-context": "^6.0" - }, - "require-dev": { - "phpunit/phpunit": "^11.2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.1-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Provides the functionality to export PHP variables for visualization", - "homepage": "https://www.github.com/sebastianbergmann/exporter", - "keywords": [ - "export", - "exporter" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/exporter/issues", - "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/6.1.3" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T04:56:19+00:00" - }, - { - "name": "sebastian/global-state", - "version": "7.0.2", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "3be331570a721f9a4b5917f4209773de17f747d7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", - "reference": "3be331570a721f9a4b5917f4209773de17f747d7", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" - }, - "require-dev": { - "ext-dom": "*", - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "7.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Snapshotting of global state", - "homepage": "https://www.github.com/sebastianbergmann/global-state", - "keywords": [ - "global state" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/global-state/issues", - "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T04:57:36+00:00" - }, - { - "name": "sebastian/lines-of-code", - "version": "3.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", - "shasum": "" - }, - "require": { - "nikic/php-parser": "^5.0", - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library for counting the lines of code in PHP source code", - "homepage": "https://github.com/sebastianbergmann/lines-of-code", - "support": { - "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T04:58:38+00:00" - }, - { - "name": "sebastian/object-enumerator", - "version": "6.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Traverses array structures and object graphs to enumerate all referenced objects", - "homepage": "https://github.com/sebastianbergmann/object-enumerator/", - "support": { - "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T05:00:13+00:00" - }, - { - "name": "sebastian/object-reflector", - "version": "4.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Allows reflection of object attributes, including inherited and non-public ones", - "homepage": "https://github.com/sebastianbergmann/object-reflector/", - "support": { - "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T05:01:32+00:00" - }, - { - "name": "sebastian/recursion-context", - "version": "6.0.2", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "694d156164372abbd149a4b85ccda2e4670c0e16" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16", - "reference": "694d156164372abbd149a4b85ccda2e4670c0e16", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" - } - ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "https://github.com/sebastianbergmann/recursion-context", - "support": { - "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-07-03T05:10:34+00:00" - }, - { - "name": "sebastian/type", - "version": "5.1.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/461b9c5da241511a2a0e8f240814fb23ce5c0aac", - "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "5.1-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Collection of value objects that represent the types of the PHP type system", - "homepage": "https://github.com/sebastianbergmann/type", - "support": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/5.1.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-09-17T13:12:04+00:00" - }, - { - "name": "sebastian/version", - "version": "5.0.2", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "5.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", - "support": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2024-10-09T05:16:32+00:00" - }, - { - "name": "squizlabs/php_codesniffer", - "version": "3.10.3", - "source": { - "type": "git", - "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "62d32998e820bddc40f99f8251958aed187a5c9c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/62d32998e820bddc40f99f8251958aed187a5c9c", - "reference": "62d32998e820bddc40f99f8251958aed187a5c9c", - "shasum": "" - }, - "require": { - "ext-simplexml": "*", - "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": ">=5.4.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" - }, - "bin": [ - "bin/phpcbf", - "bin/phpcs" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Greg Sherwood", - "role": "Former lead" - }, - { - "name": "Juliette Reinders Folmer", - "role": "Current lead" - }, - { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" - } - ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", - "keywords": [ - "phpcs", - "standards", - "static analysis" - ], - "support": { - "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", - "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", - "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", - "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" - }, - "funding": [ - { - "url": "https://github.com/PHPCSStandards", - "type": "github" - }, - { - "url": "https://github.com/jrfnl", - "type": "github" - }, - { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" - } - ], - "time": "2024-09-18T10:38:58+00:00" - }, - { - "name": "symfony/console", - "version": "v7.1.5", - "source": { - "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "0fa539d12b3ccf068a722bbbffa07ca7079af9ee" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/0fa539d12b3ccf068a722bbbffa07ca7079af9ee", - "reference": "0fa539d12b3ccf068a722bbbffa07ca7079af9ee", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/polyfill-mbstring": "~1.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^6.4|^7.0" - }, - "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" - }, - "provide": { - "psr/log-implementation": "1.0|2.0|3.0" - }, - "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Console\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Eases the creation of beautiful and testable command line interfaces", - "homepage": "https://symfony.com", - "keywords": [ - "cli", - "command-line", - "console", - "terminal" - ], - "support": { - "source": "https://github.com/symfony/console/tree/v7.1.5" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-20T08:28:38+00:00" - }, - { - "name": "symfony/deprecation-contracts", - "version": "v3.5.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", - "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-04-18T09:32:20+00:00" - }, - { - "name": "symfony/event-dispatcher", - "version": "v7.1.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "9fa7f7a21beb22a39a8f3f28618b29e50d7a55a7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9fa7f7a21beb22a39a8f3f28618b29e50d7a55a7", - "reference": "9fa7f7a21beb22a39a8f3f28618b29e50d7a55a7", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/event-dispatcher-contracts": "^2.5|^3" - }, - "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/service-contracts": "<2.5" - }, - "provide": { - "psr/event-dispatcher-implementation": "1.0", - "symfony/event-dispatcher-implementation": "2.0|3.0" - }, - "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/error-handler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.1.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-05-31T14:57:53+00:00" - }, - { - "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/8f93aec25d41b72493c6ddff14e916177c9efc50", - "reference": "8f93aec25d41b72493c6ddff14e916177c9efc50", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "psr/event-dispatcher": "^1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\EventDispatcher\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to dispatching event", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-04-18T09:32:20+00:00" - }, - { - "name": "symfony/filesystem", - "version": "v7.1.5", - "source": { - "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "61fe0566189bf32e8cfee78335d8776f64a66f5a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/61fe0566189bf32e8cfee78335d8776f64a66f5a", - "reference": "61fe0566189bf32e8cfee78335d8776f64a66f5a", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8" - }, - "require-dev": { - "symfony/process": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides basic utilities for the filesystem", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.1.5" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-17T09:16:35+00:00" - }, - { - "name": "symfony/finder", - "version": "v7.1.4", - "source": { - "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "d95bbf319f7d052082fb7af147e0f835a695e823" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/d95bbf319f7d052082fb7af147e0f835a695e823", - "reference": "d95bbf319f7d052082fb7af147e0f835a695e823", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "require-dev": { - "symfony/filesystem": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Finder\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Finds files and directories via an intuitive fluent interface", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/finder/tree/v7.1.4" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-08-13T14:28:19+00:00" - }, - { - "name": "symfony/options-resolver", - "version": "v7.1.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/options-resolver.git", - "reference": "47aa818121ed3950acd2b58d1d37d08a94f9bf55" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/47aa818121ed3950acd2b58d1d37d08a94f9bf55", - "reference": "47aa818121ed3950acd2b58d1d37d08a94f9bf55", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\OptionsResolver\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides an improved replacement for the array_replace PHP function", - "homepage": "https://symfony.com", - "keywords": [ - "config", - "configuration", - "options" - ], - "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.1.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-05-31T14:57:53+00:00" - }, - { - "name": "symfony/polyfill-ctype", - "version": "v1.31.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "provide": { - "ext-ctype": "*" - }, - "suggest": { - "ext-ctype": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for ctype functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" - ], - "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, - { - "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "suggest": { - "ext-intl": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for intl's grapheme_* functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "grapheme", - "intl", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, - { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "suggest": { - "ext-intl": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "intl", - "normalizer", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, - { - "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "provide": { - "ext-mbstring": "*" - }, - "suggest": { - "ext-mbstring": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for the Mbstring extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, - { - "name": "symfony/polyfill-php80", - "version": "v1.31.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, - { - "name": "symfony/polyfill-php81", - "version": "v1.31.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php81\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, - { - "name": "symfony/process", - "version": "v7.1.5", - "source": { - "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "5c03ee6369281177f07f7c68252a280beccba847" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/5c03ee6369281177f07f7c68252a280beccba847", - "reference": "5c03ee6369281177f07f7c68252a280beccba847", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Process\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Executes commands in sub-processes", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/process/tree/v7.1.5" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-19T21:48:23+00:00" - }, - { - "name": "symfony/service-contracts", - "version": "v3.5.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", - "reference": "bd1d9e59a81d8fa4acdcea3f617c581f7475a80f", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3" - }, - "conflict": { - "ext-psr": "<1.1|>=2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Service\\": "" - }, - "exclude-from-classmap": [ - "/Test/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to writing services", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-04-18T09:32:20+00:00" - }, - { - "name": "symfony/stopwatch", - "version": "v7.1.1", - "source": { - "type": "git", - "url": "https://github.com/symfony/stopwatch.git", - "reference": "5b75bb1ac2ba1b9d05c47fc4b3046a625377d23d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/5b75bb1ac2ba1b9d05c47fc4b3046a625377d23d", - "reference": "5b75bb1ac2ba1b9d05c47fc4b3046a625377d23d", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/service-contracts": "^2.5|^3" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Stopwatch\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides a way to profile code", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/stopwatch/tree/v7.1.1" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-05-31T14:57:53+00:00" - }, - { - "name": "symfony/string", - "version": "v7.1.5", - "source": { - "type": "git", - "url": "https://github.com/symfony/string.git", - "reference": "d66f9c343fa894ec2037cc928381df90a7ad4306" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/d66f9c343fa894ec2037cc928381df90a7ad4306", - "reference": "d66f9c343fa894ec2037cc928381df90a7ad4306", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" - }, - "conflict": { - "symfony/translation-contracts": "<2.5" - }, - "require-dev": { - "symfony/emoji": "^7.1", - "symfony/error-handler": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", - "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "files": [ - "Resources/functions.php" - ], - "psr-4": { - "Symfony\\Component\\String\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", - "homepage": "https://symfony.com", - "keywords": [ - "grapheme", - "i18n", - "string", - "unicode", - "utf-8", - "utf8" - ], - "support": { - "source": "https://github.com/symfony/string/tree/v7.1.5" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-20T08:28:38+00:00" - }, - { - "name": "symfony/yaml", - "version": "v7.1.5", - "source": { - "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "4e561c316e135e053bd758bf3b3eb291d9919de4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/4e561c316e135e053bd758bf3b3eb291d9919de4", - "reference": "4e561c316e135e053bd758bf3b3eb291d9919de4", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "symfony/console": "<6.4" - }, - "require-dev": { - "symfony/console": "^6.4|^7.0" - }, - "bin": [ - "Resources/bin/yaml-lint" - ], - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Yaml\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Loads and dumps YAML files", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/yaml/tree/v7.1.5" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-17T12:49:58+00:00" - }, - { - "name": "theseer/tokenizer", - "version": "1.2.3", - "source": { - "type": "git", - "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - } - ], - "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", - "support": { - "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" - }, - "funding": [ - { - "url": "https://github.com/theseer", - "type": "github" - } - ], - "time": "2024-03-03T12:36:25+00:00" - } - ], + "content-hash": "dbf582b281a716bd010895a27508485f", + "packages": [], + "packages-dev": [], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], - "prefer-stable": false, + "stability-flags": {}, + "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.3" + "php": "^8.4" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/create_structure.php b/create_structure.php deleted file mode 100644 index 9262398..0000000 --- a/create_structure.php +++ /dev/null @@ -1,46 +0,0 @@ - ['DotenvTest.php'], - 'tests/Unit/Loader' => ['ArrayLoaderTest.php', 'FileLoaderTest.php'], - 'tests/Unit/Parser' => ['DefaultParserTest.php', 'StrictParserTest.php'], - 'tests/Unit/Type/Caster' => [ - 'ArrayCasterTest.php', - 'BooleanCasterTest.php', - 'FloatCasterTest.php', - 'IntegerCasterTest.php', - 'JsonCasterTest.php', - 'NullCasterTest.php', - 'StringCasterTest.php', - 'TypeCasterRegistryTest.php' - ], - 'tests/Unit/Type/Detector' => [ - 'ArrayDetectorTest.php', - 'BooleanDetectorTest.php', - 'JsonDetectorTest.php', - 'NullDetectorTest.php', - 'NumericDetectorTest.php', - 'StringDetectorTest.php', - 'TypeDetectorRegistryTest.php' - ], - 'tests/Unit' => ['DotenvTest.php', 'DotenvFactoryTest.php'], - 'tests/Integration' => ['DotenvIntegrationTest.php', 'TypeSystemIntegrationTest.php'], - 'tests/Functional' => ['DotenvFunctionalTest.php'] -]; - -foreach ($structure as $dir => $files) { - // Cria o diretório, caso não exista - if (!is_dir($dir)) { - mkdir($dir, 0777, true); - echo "Diretório criado: $dir\n"; - } - - // Cria os arquivos dentro do diretório - foreach ($files as $file) { - $filePath = $dir . '/' . $file; - file_put_contents($filePath, "strictNames, + // ... named arguments for all parameters + ); + } +} +``` + +Each `with*()` method returns a new instance using named arguments, ensuring: +1. No parameter ordering bugs — named args are position-independent. +2. Adding a new constructor parameter requires no changes to existing `with*()` methods (if a default is provided). +3. The original instance is never modified. + +### EnvironmentVariable + +```php +final readonly class EnvironmentVariable +{ + public function __construct( + public string $name, + public string $rawValue, + public ValueType $type, + public mixed $value, + public string $source = '', + public bool $overridden = false, + ) {} +} +``` + +Once a variable is parsed, typed, and stored, its representation is sealed. The `overridden` flag records lineage without mutating prior state — the previous `EnvironmentVariable` is simply replaced in the `$variables` array. + +### Enums as Immutable Discriminators + +`LoadMode` and `ValueType` are backed-less PHP 8.4 enums — they carry identity without mutable backing values: + +```php +enum LoadMode { case Immutable; case Overwrite; case SkipExisting; } +enum ValueType { case String; case Integer; case Float; case Boolean; case Null; case Json; case Array; } +``` + +## Consequences + +**Positive:** +- Thread-safe by construction — no locks needed in async runtimes. +- Predictable behavior — configuration observed at time T₁ is identical at T₂. +- IDE support — readonly properties enable aggressive static analysis (PHPStan level 9). +- Defensive copying is unnecessary — readonly eliminates accidental mutation. + +**Negative:** +- Configuration changes require instantiating new `DotenvConfiguration` objects via `with*()` methods. +- Cannot use property hooks (PHP 8.4) on readonly promoted properties — a language-level constraint. + +**Trade-off accepted:** The cost of object allocation for `with*()` calls is negligible at bootstrap time (once per application lifecycle), making immutability effectively free. diff --git a/docs/adr/ADR-003-type-system.md b/docs/adr/ADR-003-type-system.md new file mode 100644 index 0000000..ec5659b --- /dev/null +++ b/docs/adr/ADR-003-type-system.md @@ -0,0 +1,84 @@ +# ADR-003: Pluggable Type System with Priority-Based Detection + +**Status:** Accepted +**Date:** 2024-01-20 +**Applies to:** KaririCode\Dotenv 4.0+ + +## Context + +Environment variables are strings by definition (POSIX, IEEE Std 1003.1). However, application code universally needs typed values: booleans for feature flags, integers for ports, arrays for IP whitelists. The conversion from string to typed value involves two distinct operations: + +1. **Detection** — determining the semantic type of a string value. +2. **Casting** — converting the string to the detected PHP type. + +These operations must be extensible (applications may define custom types) and deterministic (same input always produces the same type). + +## Decision + +### Two-Phase Architecture + +The `TypeSystem` orchestrates detection and casting as separate, composable phases: + +``` +Raw String → [Detector₁, Detector₂, ..., Detectorₙ] → ValueType → Caster → Typed Value +``` + +**Phase 1 — Detection:** Detectors are sorted by priority (descending). The first detector returning a non-null `ValueType` wins. If no detector matches, the value remains `ValueType::String`. + +**Phase 2 — Casting:** The `ValueType` selects the corresponding `TypeCaster`. `ValueType::String` short-circuits — no caster is invoked. + +### Priority Order + +| Detector | Priority | Rationale | +|---|---|---| +| NullDetector | 200 | `"null"` must not be detected as a string. | +| BooleanDetector | 190 | `"true"`/`"false"` must not be detected as strings. | +| IntegerDetector | 180 | `"42"` must be integer, not float. | +| FloatDetector | 170 | `"3.14"` must contain `.` or `e` to avoid integer confusion. | +| JsonDetector | 160 | `{...}` objects before array check. | +| ArrayDetector | 150 | `[...]` arrays. | +| *(String)* | *(fallback)* | Everything else. | + +The gap between priorities (10) allows custom detectors to be inserted at any position. + +### Contracts + +```php +interface TypeDetector { + public function priority(): int; + public function detect(string $value): ?ValueType; +} + +interface TypeCaster { + public function cast(string $value): mixed; +} +``` + +### Lazy Sorting + +Detectors are sorted only when `detect()` is first called, and re-sorted only when `addDetector()` is called. A `$sorted` flag avoids redundant `usort()` calls. + +### Extension Point + +```php +$dotenv->addTypeDetector(new SemVerDetector()); // Custom detector at any priority +$dotenv->addTypeCaster(ValueType::Integer, new StrictIntCaster()); // Replace default +``` + +Adding a detector invalidates the sort. Adding a caster replaces the existing one for that `ValueType`. + +## Consequences + +**Positive:** +- Open/Closed Principle — new types require no modification to `TypeSystem`. +- Deterministic — priority ordering eliminates ambiguity (`"true"` is always boolean, never string). +- Testable — each detector/caster is a pure function, testable in isolation. +- Zero overhead for strings — fallback path invokes no caster. + +**Negative:** +- Priority collisions: two detectors at the same priority have undefined ordering (stable sort, but insertion-order dependent). +- No composite types: a value cannot be detected as multiple types simultaneously. + +**Mitigations:** +- Document that custom detectors should use priorities not in the default range (100–200). +- The `resolve()` convenience method chains detect + cast for the common case. diff --git a/docs/adr/ADR-004-encryption-format.md b/docs/adr/ADR-004-encryption-format.md new file mode 100644 index 0000000..8507179 --- /dev/null +++ b/docs/adr/ADR-004-encryption-format.md @@ -0,0 +1,89 @@ +# ADR-004: AES-256-GCM Encryption Format + +**Status:** Accepted +**Date:** 2024-02-10 +**Applies to:** KaririCode\Dotenv 4.3+ + +## Context + +Committing secrets to version control is the #1 source of credential leaks in modern applications. The traditional mitigation — external secret managers (Vault, AWS Secrets Manager, GCP KMS) — adds operational complexity and a runtime dependency on network availability. For many teams, the pragmatic solution is encrypting secrets directly in `.env` files, allowing them to be committed safely alongside application code. + +Requirements: +1. Per-value encryption (not whole-file) — plaintext and encrypted values coexist. +2. Authenticated encryption — ciphertext integrity must be verifiable (no silent corruption). +3. No external tools or runtimes — pure PHP using bundled extensions. +4. Unique ciphertext per encryption — same plaintext must produce different ciphertext each time. +5. Self-describing format — encrypted values must be distinguishable from plaintext without metadata. + +## Decision + +### Algorithm: AES-256-GCM + +AES-256-GCM is an AEAD (Authenticated Encryption with Associated Data) cipher that provides: +- **Confidentiality:** 256-bit AES encryption. +- **Integrity:** 128-bit GCM authentication tag detects tampering. +- **Performance:** Hardware-accelerated via AES-NI on modern CPUs. + +PHP's ext-openssl (bundled in standard distributions) provides `openssl_encrypt()` / `openssl_decrypt()` with native GCM support. + +### Wire Format + +``` +encrypted: +``` + +| Field | Size | Description | +|---|---|---| +| Prefix | 10 bytes | ASCII literal `encrypted:` — enables `str_starts_with()` detection. | +| Nonce | 12 bytes | Random IV per encryption. GCM requires exactly 96 bits. | +| Ciphertext | variable | AES-256-GCM output. 0 bytes for empty plaintext. | +| Tag | 16 bytes | GCM authentication tag (128 bits). | + +The entire binary payload (nonce + ciphertext + tag) is base64-encoded into a single string. + +### Key Format + +Keys are 256-bit (32 bytes) values. Two representations are accepted: +- **Hex string:** 64 hexadecimal characters (e.g., `a1b2c3d4...`). Stored in `DOTENV_PRIVATE_KEY`. +- **Raw binary:** 32 bytes. Used internally after hex decoding. + +The `KeyPair` class generates keys via `random_bytes(32)` and derives an 8-character public ID from `sha256(raw_key)[0:8]` for multi-environment key management. + +### Encryption Flow + +``` +plaintext → random_bytes(12) → openssl_encrypt(AES-256-GCM) → "encrypted:" + base64(nonce ‖ ciphertext ‖ tag) +``` + +### Decryption Flow + +``` +"encrypted:..." → strip prefix → base64_decode → split(nonce, ciphertext, tag) → openssl_decrypt → plaintext +``` + +Non-encrypted values (`Encryptor::isEncrypted() === false`) pass through unchanged. + +### Security Properties + +1. **Nonce uniqueness:** `random_bytes(12)` ensures probabilistic uniqueness. Nonce reuse with the same key is cryptographically catastrophic for GCM; random generation provides 2⁹⁶ space. +2. **Authentication:** GCM tag prevents bit-flipping and truncation attacks. `openssl_decrypt()` returns `false` on tag mismatch. +3. **Key validation:** Constructor rejects keys that are not exactly 32 bytes after hex decoding. +4. **No padding oracle:** GCM is a streaming mode — no padding, no padding oracle. + +## Consequences + +**Positive:** +- Secrets can be safely committed to VCS — only the private key must be protected. +- No runtime dependency on external services — decryption is local and instant. +- Self-describing format — `encrypted:` prefix enables transparent decryption in the load pipeline. +- Each encryption produces unique ciphertext — safe for audit logs and diff tools. + +**Negative:** +- Key rotation requires re-encrypting all values. The CLI `encrypt` command handles this. +- ext-openssl must be present. It is bundled in all standard PHP distributions but may be absent in Alpine-based minimal images. +- No key derivation function (KDF) — the key must be high-entropy (random bytes), not a password. + +**Rejected alternatives:** +- **libsodium (XChaCha20-Poly1305):** Stronger nonce safety (192-bit nonce) but ext-sodium is not bundled in all distributions. AES-256-GCM with random 96-bit nonces is safe for the expected volume (< 2³² encryptions per key). +- **Whole-file encryption:** Would prevent `git diff` on plaintext values and require decrypting everything to read a single variable. +- **Envelope encryption (AWS KMS pattern):** Adds network dependency and complexity disproportionate to the use case. diff --git a/docs/adr/ADR-005-opcache-cache.md b/docs/adr/ADR-005-opcache-cache.md new file mode 100644 index 0000000..b260069 --- /dev/null +++ b/docs/adr/ADR-005-opcache-cache.md @@ -0,0 +1,104 @@ +# ADR-005: OPcache-Friendly PHP Array Cache + +**Status:** Accepted +**Date:** 2024-02-15 +**Applies to:** KaririCode\Dotenv 4.2+ + +## Context + +In production, `.env` files are static — they change only during deployments. Parsing the same file on every request wastes CPU cycles. The ideal cache format should: + +1. Eliminate file I/O and string parsing entirely after first load. +2. Leverage existing PHP infrastructure (no external cache servers). +3. Be atomic — no partial reads during deployment. +4. Self-invalidate when source files change. + +## Decision + +### Format: `var_export()` PHP Array + +The cache is a plain PHP file that returns an associative array: + +```php + + array ( + 'generated_at' => '2024-02-15T10:30:00+00:00', + 'source_hash' => 'a1b2c3d4e5f6...', + 'generator' => 'KaririCode\\Dotenv v4.x', + ), + 'DB_HOST' => 'localhost', + 'DB_PORT' => '5432', + 'APP_DEBUG' => 'true', +); +``` + +When OPcache is enabled, PHP compiles this file once into shared memory. Subsequent `include` calls load directly from shared memory — no disk I/O, no parsing, no lexing. This is the fastest possible read path in PHP. + +### Atomic Writes + +```php +$tmpFile = $path . '.tmp.' . getmypid(); +file_put_contents($tmpFile, $content); +rename($tmpFile, $path); +opcache_invalidate($path, true); +``` + +1. Write to a temporary file (PID-suffixed to avoid collisions). +2. Atomic `rename()` — POSIX guarantees this is atomic on the same filesystem. +3. Invalidate OPcache for the target path so the new version is picked up. + +### Staleness Detection + +The `__metadata.source_hash` is computed from file modification times and sizes of all source `.env` files: + +```php +$context = hash_init('md5'); +foreach ($filePaths as $filePath) { + hash_update($context, (string) filemtime($filePath)); + hash_update($context, (string) filesize($filePath)); +} +return hash_final($context); +``` + +On load, if the stored hash doesn't match the current source hash, the cache is considered stale and bypassed. This avoids content hashing (expensive for large files) while still detecting changes. + +### Load Flow + +``` +load() called + → cachePath configured? + → No: parse .env files normally + → Yes: compute source hash → load cache file → hash matches? + → Yes: use cached variables (zero parse cost) + → No: parse .env files normally (cache is stale) +``` + +### Raw String Storage + +The cache stores **raw string values**, not typed values. Type detection and casting run after cache load, ensuring that custom detectors/casters registered at runtime are applied consistently regardless of whether the source was cache or file. + +## Consequences + +**Positive:** +- Zero parse cost in production when OPcache is enabled — variables load from shared memory. +- No external cache dependency (Redis, Memcached, APCu). +- Atomic writes prevent partial reads during deployment. +- Automatic staleness detection without manual cache clearing. +- `var_export()` produces valid PHP that survives OPcache restarts. + +**Negative:** +- Requires filesystem write access in the deployment step. +- OPcache must be enabled for the full performance benefit (still faster than parsing without OPcache due to `include` vs string parsing). +- MD5-based hash is not cryptographically secure — but it only needs collision resistance for cache invalidation, not security. + +**Rejected alternatives:** +- **APCu cache:** Requires ext-apcu and doesn't survive OPcache resets. +- **JSON cache:** Requires `json_decode()` on every request — slower than `include`. +- **Serialized PHP:** `unserialize()` is slower than `include` for array data and has security implications. +- **Content hashing (SHA-256 of file contents):** Correct but expensive — reading entire file contents to check if cache is valid defeats the purpose. diff --git a/docs/adr/ADR-006-load-modes.md b/docs/adr/ADR-006-load-modes.md new file mode 100644 index 0000000..32fbb07 --- /dev/null +++ b/docs/adr/ADR-006-load-modes.md @@ -0,0 +1,79 @@ +# ADR-006: Three Load Modes for Environment Variable Precedence + +**Status:** Accepted +**Date:** 2024-01-20 +**Applies to:** KaririCode\Dotenv 4.0+ + +## Context + +When a `.env` file declares `DB_HOST=localhost` but the operating system already has `DB_HOST=production-host` set via the container orchestrator, which value wins? The answer depends on the deployment model: + +- **Containerized (Kubernetes, ECS):** The orchestrator injects real secrets via environment variables. The `.env` file contains development defaults. Real environment must take precedence. +- **Traditional server:** The `.env` file is the source of truth. No environment variables are pre-set. +- **Development:** Developers want `.env` to override everything for local testing. + +A single behavior cannot satisfy all three models. + +## Decision + +Three mutually exclusive load modes, selected via `DotenvConfiguration::$loadMode`: + +### `LoadMode::Immutable` (Default) + +Throws `ImmutableException` if a `.env` variable conflicts with a pre-existing environment variable (one that existed **before** `load()` was called). Variables loaded by the Dotenv instance itself can be overridden across multiple files (e.g., `.env` → `.env.local`). + +**Rationale:** The safest default. If the container orchestrator set `DB_PASSWORD`, the `.env` file's value is almost certainly stale. Throwing an exception surfaces the conflict immediately rather than silently using the wrong value. + +**Scope of immutability:** Only variables that existed in `$_ENV`, `$_SERVER`, or `getenv()` before the Dotenv instance started loading are protected. Variables introduced by earlier `.env` files in the same load sequence are not protected — this allows cascade loading (`.env` → `.env.local`) to work naturally. + +### `LoadMode::Overwrite` + +Always overwrites, regardless of prior state. The `.env` file is the absolute source of truth. + +**Use case:** Development environments, test suites, and applications where the `.env` file is managed by deployment tooling (Ansible, Chef) and is the canonical source. + +### `LoadMode::SkipExisting` + +Silently skips any variable that already exists in the environment. No exception, no overwrite. + +**Use case:** Docker images that set sensible defaults in `.env` but allow runtime overrides via `docker run -e DB_HOST=...`. The `.env` provides fallbacks only. + +### Decision Matrix + +| Scenario | Pre-existing var | .env var | Immutable | Overwrite | SkipExisting | +|---|---|---|---|---|---| +| Conflict | `DB_HOST=prod` | `DB_HOST=dev` | ❌ throws | `dev` wins | `prod` wins | +| No conflict | *(absent)* | `DB_HOST=dev` | `dev` set | `dev` set | `dev` set | +| Cascade | `.env`: `X=a` | `.env.local`: `X=b` | `b` wins | `b` wins | `b` wins | + +### Implementation + +The check occurs in `Dotenv::setVariable()`: + +```php +$alreadyExists = isset($_ENV[$name]) || isset($_SERVER[$name]) || getenv($name) !== false; + +if ($this->configuration->loadMode === LoadMode::Immutable + && $alreadyExists + && !isset($this->variables[$name]) // Not loaded by this instance +) { + throw ImmutableException::alreadyDefined($name); +} + +if ($this->configuration->loadMode === LoadMode::SkipExisting && $alreadyExists) { + return; +} +``` + +The `!isset($this->variables[$name])` guard is critical — it distinguishes between "existed before Dotenv" (immutable violation) and "loaded by a prior .env file in this instance" (cascade override, allowed). + +## Consequences + +**Positive:** +- Explicit control over precedence — no guessing which value is active. +- Safe default (Immutable) prevents silent credential conflicts in production. +- All three modes share the same code path — the only difference is the conflict resolution branch. + +**Negative:** +- Immutable mode can cause startup failures if the deployment pipeline inadvertently sets variables that conflict with `.env`. This is by design — the failure surfaces the misconfiguration. +- SkipExisting mode can silently mask stale `.env` values. Developers must understand that pre-existing environment variables always win. diff --git a/docs/adr/ADR-007-environment-cascade.md b/docs/adr/ADR-007-environment-cascade.md new file mode 100644 index 0000000..7b58a07 --- /dev/null +++ b/docs/adr/ADR-007-environment-cascade.md @@ -0,0 +1,91 @@ +# ADR-007: Environment-Aware Cascade Loading + +**Status:** Accepted +**Date:** 2024-02-20 +**Applies to:** KaririCode\Dotenv 4.2+ + +## Context + +Real-world applications operate across multiple environments: development, testing, staging, production. Each environment shares a common base configuration but overrides specific values. The traditional approach — maintaining separate `.env.production`, `.env.staging` files — leads to duplication and drift. + +A cascade loading strategy loads multiple files in a defined order, where later files override earlier ones. This allows: +- Base defaults in `.env` (committed to VCS) +- Local developer overrides in `.env.local` (gitignored) +- Environment-specific values in `.env.{env}` (committed) +- Environment-specific local overrides in `.env.{env}.local` (gitignored) + +## Decision + +### `bootEnv()` Method + +The `Dotenv::bootEnv()` method implements a four-layer cascade: + +``` +Layer 1: .env (base defaults, committed) +Layer 2: .env.local (local overrides, gitignored) +Layer 3: .env.{APP_ENV} (environment defaults, committed) +Layer 4: .env.{APP_ENV}.local (environment local overrides, gitignored) +``` + +Each layer is loaded with `required: false` — missing files are silently skipped. + +### Environment Name Resolution + +The environment name is resolved in priority order: + +1. **Explicit parameter:** `$dotenv->bootEnv('production')` — highest priority. +2. **Configuration:** `DotenvConfiguration::$environmentName`. +3. **Loaded variable:** The `APP_ENV` variable from Layer 1 or Layer 2. +4. **System environment:** `$_ENV['APP_ENV']` or `$_SERVER['APP_ENV']`. +5. **Default:** `'dev'`. + +This order allows `.env` to declare `APP_ENV=staging` and have Layer 3 load `.env.staging` accordingly. + +### Test Environment Safety + +When the resolved environment is `test`, Layer 4 (`.env.test.local`) is **not loaded**. This ensures test suite reproducibility — local developer overrides must not affect CI/CD test runs. + +```php +if ($envName !== 'test') { + $this->loadFile("{$basePath}.{$envName}.local", required: false); +} +``` + +### Gitignore Convention + +The following `.gitignore` entries are recommended: + +```gitignore +.env.local +.env.*.local +.env.cache.php +``` + +This keeps local overrides out of VCS while allowing `.env`, `.env.staging`, and `.env.production` to be committed as shared configuration. + +### Cache Integration + +`bootEnv()` checks for a cache file **before** any cascade loading. If the cache is fresh, no files are parsed. The cache should be dumped after the full cascade has been resolved (i.e., in the deployment pipeline). + +```php +public function bootEnv(?string $environmentName = null): void +{ + if ($this->loadFromCache()) { + $this->loaded = true; + return; + } + // ... cascade loading +} +``` + +## Consequences + +**Positive:** +- Single `.env` committed to VCS with environment-specific overrides — no duplication. +- Developers customize freely via `.env.local` without affecting teammates. +- Test reproducibility guaranteed by skipping `.env.test.local`. +- Compatible with cache: deploy pipeline dumps cache after cascade resolution. + +**Negative:** +- Four-layer cascade can be confusing for debugging ("which file set this value?"). The `debug()` method mitigates this by reporting the source file for each variable. +- Layer 2 (`.env.local`) loads before the environment is resolved, meaning it cannot be environment-specific. This is intentional — `.env.local` is for machine-specific overrides (paths, ports), not environment-specific logic. diff --git a/docs/adr/ADR-008-validation-strategy.md b/docs/adr/ADR-008-validation-strategy.md new file mode 100644 index 0000000..e4df66e --- /dev/null +++ b/docs/adr/ADR-008-validation-strategy.md @@ -0,0 +1,134 @@ +# ADR-008: Batch Error Validation with Fluent DSL + +**Status:** Accepted +**Date:** 2024-02-05 +**Applies to:** KaririCode\Dotenv 4.1+ + +## Context + +Environment validation traditionally follows a fail-fast model: check the first variable, throw on failure, fix, re-run, discover the next failure. For applications with 20+ required variables, this creates a frustrating fix-one-discover-one loop that can require dozens of restart cycles during initial setup. + +Requirements: +1. Collect **all** validation failures in a single pass. +2. Provide a fluent API that reads like a specification. +3. Support conditional validation (validate only if variable is present). +4. Allow custom validation logic without subclassing. +5. Separate validation definition from execution (define rules, then `assert()`). + +## Decision + +### Two-Phase Validation + +**Phase 1 — Rule Collection:** The fluent API collects rules without executing them. Each method returns `$this` for chaining. + +**Phase 2 — Assertion:** `assert()` executes all collected rules and throws a single `ValidationException` containing every failure message. + +```php +$dotenv->validate() + ->required('DB_HOST', 'DB_PORT') // Phase 1: collect + ->isInteger('DB_PORT')->between(1, 65535) + ->allowedValues('APP_ENV', ['local', 'staging', 'production']) + ->assert(); // Phase 2: execute all +``` + +### Error Collection + +```php +public function assert(): void +{ + $errors = []; + + // Check required presence first + foreach ($this->requiredNames as $name) { + if (($this->valueResolver)($name) === null) { + $errors[] = "{$name} is required but not defined."; + } + } + + // Run rules per variable + foreach ($this->rules as $name => $ruleList) { + $value = ($this->valueResolver)($name); + if ($value === null) { continue; } // Skip absent (already reported or conditional) + + foreach ($ruleList as $rule) { + if (!$rule->passes($value)) { + $errors[] = str_replace('{name}', $name, $rule->message()); + } + } + } + + if ($errors !== []) { + throw ValidationException::batchErrors($errors); + } +} +``` + +The `ValidationException` carries both a human-readable message and a structured `errors(): array` for programmatic inspection. + +### Fluent Targeting + +The validator maintains a `$currentTargets` array that tracks which variables subsequent rules apply to. Targeting methods update this state: + +- `required('A', 'B')` — sets targets to `['A', 'B']`, marks as required. +- `ifPresent('C')` — sets targets to `['C']`, enables conditional mode. +- `isInteger('D')` — adds IsIntegerRule to `D`, sets target to `['D']`. +- `between(1, 65535)` — adds BetweenRule to current targets (inherits from prior call). + +This allows natural chaining: `->isInteger('PORT')->between(1, 65535)` applies both rules to `PORT`. + +### Conditional Validation (`ifPresent`) + +Variables targeted via `ifPresent()` are tracked in a `$conditionalNames` map. During `assert()`, if a conditional variable is absent, its rules are silently skipped: + +```php +if ($value === null && $this->isConditional($name)) { + continue; // No error for absent conditional variable +} +``` + +This supports optional configuration: "If REDIS_HOST is set, it must not be empty." + +### ValidationRule Contract + +```php +interface ValidationRule { + public function passes(string $value): bool; + public function message(): string; // "{name}" placeholder replaced at assertion time +} +``` + +All 10 built-in rules implement this contract. Custom rules are added via: + +```php +$validator->rule(new CustomRule(), 'VAR_NAME'); +// or +$validator->custom('VAR_NAME', fn(string $v) => strlen($v) >= 8, '{name} must be at least 8 characters.'); +``` + +### Built-in Rules + +| Rule | Validation | +|---|---| +| NotEmptyRule | `trim($value) !== ''` | +| IsIntegerRule | `/\A[+-]?\d+\z/` | +| IsBooleanRule | `true/false/1/0/yes/no/on/off` | +| IsNumericRule | `is_numeric()` | +| BetweenRule | `$value >= $min && $value <= $max` | +| AllowedValuesRule | `in_array($value, $allowed, true)` | +| MatchesRegexRule | `preg_match($pattern, $value)` | +| UrlRule | `filter_var(FILTER_VALIDATE_URL)` | +| EmailRule | `filter_var(FILTER_VALIDATE_EMAIL)` | +| CustomRule | User-provided `Closure(string): bool` | + +## Consequences + +**Positive:** +- All errors surfaced in one pass — setup/debugging cycle reduced from O(n) restarts to O(1). +- Fluent API is readable as a specification document. +- Conditional validation avoids false positives for optional configuration. +- Custom rules require no subclassing — closures or `ValidationRule` implementations work equally. +- `{name}` placeholder in messages enables reusable rule instances across variables. + +**Negative:** +- Rule execution order within a variable is insertion order, not dependency order. A `between()` rule on a non-integer value will fail with a between error rather than a type error. Mitigation: chain `isInteger()` before `between()` — both failures will be reported. +- `$currentTargets` state makes the validator stateful during construction. This is acceptable because the validator is a short-lived builder object, not a long-lived service. diff --git a/docs/spec/SPEC-001-env-syntax.md b/docs/spec/SPEC-001-env-syntax.md new file mode 100644 index 0000000..108c55c --- /dev/null +++ b/docs/spec/SPEC-001-env-syntax.md @@ -0,0 +1,234 @@ +# SPEC-001: .env File Syntax + +**Version:** 1.0 +**Date:** 2024-01-15 +**Applies to:** KaririCode\Dotenv 4.0+ + +## 1. Overview + +This specification defines the `.env` file syntax supported by KaririCode\Dotenv. The syntax is a superset of POSIX shell variable assignment, compatible with Docker `.env` files and Bash variable declarations. + +## 2. File Encoding + +Files must be UTF-8 encoded. BOM (Byte Order Mark) is not supported. Line endings are normalized: CRLF (`\r\n`) and CR (`\r`) are converted to LF (`\n`) before parsing. + +## 3. Line Types + +### 3.1 Empty Lines + +Empty lines and lines containing only whitespace are ignored. + +### 3.2 Comments + +Lines where the first non-whitespace character is `#` are comments and are ignored entirely. + +```ini +# This is a comment + # This is also a comment (leading whitespace allowed) +``` + +### 3.3 Variable Assignments + +``` +NAME=VALUE +``` + +The `=` separator is required. Whitespace around `=` is stripped from the name (right side) and value (left side): + +```ini +FOO = bar # name="FOO", value="bar" +FOO =bar # name="FOO", value="bar" +FOO= bar # name="FOO", value="bar" +``` + +### 3.4 Bare Names + +A line with a valid name but no `=` is treated as an empty string assignment: + +```ini +FOO # name="FOO", value="" +``` + +### 3.5 Export Prefix + +The `export` keyword is silently stripped: + +```ini +export FOO=bar # equivalent to FOO=bar +``` + +## 4. Variable Names + +### 4.1 Default Mode (strictNames: false) + +Pattern: `[A-Za-z_][A-Za-z0-9_.]*` + +Valid: `FOO`, `foo_bar`, `App.Config`, `_PRIVATE`, `DB_HOST_1` +Invalid: `1FOO` (starts with digit), `FOO-BAR` (contains hyphen), `FOO BAR` (contains space) + +### 4.2 Strict Mode (strictNames: true) + +Pattern: `[A-Z][A-Z0-9_]*` + +Valid: `FOO`, `DB_HOST`, `APP_ENV_1` +Invalid: `foo` (lowercase), `_FOO` (starts with underscore), `App.Config` (dots and lowercase) + +Strict mode enforces the POSIX convention for environment variable names. + +## 5. Values + +### 5.1 Unquoted Values + +Everything after `=` until the end of line, with: +- Trailing inline comments stripped: `FOO=bar # comment` → `"bar"` +- Leading and trailing whitespace trimmed +- Variable interpolation applied (§6) + +```ini +FOO=hello world # "hello world" +FOO=hello world # note # "hello world" +``` + +### 5.2 Double-Quoted Values + +Delimited by `"..."`. Support: +- Escape sequences (§5.4) +- Variable interpolation (§6) +- Multiline values (§5.5) + +```ini +FOO="hello world" # "hello world" +FOO="hello # world" # "hello # world" (# is not a comment inside quotes) +``` + +### 5.3 Single-Quoted Values + +Delimited by `'...'`. Contents are literal — no escape processing, no interpolation. + +```ini +FOO='hello $WORLD' # "hello $WORLD" (literal dollar sign) +FOO='hello\nworld' # "hello\nworld" (literal backslash-n) +``` + +### 5.4 Escape Sequences (Double-Quoted Only) + +| Sequence | Result | +|---|---| +| `\n` | Newline (LF) | +| `\r` | Carriage return (CR) | +| `\t` | Tab | +| `\"` | Literal double quote | +| `\\` | Literal backslash | +| `\$` | Literal dollar sign (suppresses interpolation) | +| `\x` (other) | Literal `\x` (unknown escapes pass through) | + +### 5.5 Multiline Values + +Double-quoted values can span multiple lines. The newlines are preserved literally: + +```ini +RSA_KEY="-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA... +-----END RSA PRIVATE KEY-----" +``` + +Single-quoted values cannot span multiple lines — an unterminated single quote on the same line is a parse error. + +### 5.6 Empty Values + +```ini +FOO= # "" (empty string) +FOO="" # "" (empty string) +FOO='' # "" (empty string) +``` + +## 6. Variable Interpolation + +Interpolation is applied in **unquoted** and **double-quoted** values. Single-quoted values are never interpolated. + +### 6.1 Resolution Order + +Variables are resolved against: +1. Already-parsed variables in the current file (top-to-bottom) +2. `$_ENV` +3. `$_SERVER` +4. Empty string (if unresolved) + +### 6.2 Brace Syntax + +```ini +FOO=${BAR} # Value of BAR +FOO=${BAR:-default} # BAR if set and non-empty, else "default" +FOO=${BAR:+alternate} # "alternate" if BAR is set and non-empty, else "" +``` + +### 6.3 Bare Syntax + +```ini +FOO=$BAR # Value of BAR (terminated by non-alphanumeric/non-underscore) +FOO=$BAR_BAZ # Value of BAR_BAZ (underscores are part of the name) +``` + +### 6.4 Operator Semantics + +| Operator | BAR is set and non-empty | BAR is unset or empty | +|---|---|---| +| `${BAR}` | Value of BAR | `""` | +| `${BAR:-default}` | Value of BAR | `"default"` | +| `${BAR:+alternate}` | `"alternate"` | `""` | + +### 6.5 Nesting Limitation + +Nested interpolation within operator operands is not supported: + +```ini +# NOT supported: +FOO=${BAR:-${BAZ}} # The operand is the literal string "${BAZ}" +``` + +This matches the behavior of simple shell implementations. For complex defaults, use multiple lines: + +```ini +BAZ_DEFAULT=fallback +FOO=${BAR:-${BAZ_DEFAULT}} # Still not supported; use: +# BAZ=fallback +# FOO=${BAR:-fallback} +``` + +## 7. Inline Comments + +Comments starting with `#` preceded by whitespace are stripped from **unquoted values only**: + +```ini +FOO=bar # comment # "bar" +FOO="bar # not comment" # "bar # not comment" +FOO='bar # not comment' # "bar # not comment" +FOO=bar#notacomment # "bar#notacomment" (no preceding whitespace) +``` + +## 8. Error Handling + +| Condition | Exception | +|---|---| +| Invalid variable name | `ParseException::invalidVariableName()` | +| Unterminated double quote | `ParseException::unterminatedQuote()` | +| Unterminated single quote | `ParseException::unterminatedQuote()` | + +All exceptions include the line number and file path for diagnostics. + +## 9. Grammar (Informal BNF) + +``` +file = { line LF } +line = empty | comment | assignment +empty = [ WS ] +comment = [ WS ] "#" TEXT +assignment = [ "export" WS ] NAME [ WS ] "=" [ WS ] value +NAME = [A-Za-z_] [A-Za-z0-9_.]* +value = double_quoted | single_quoted | unquoted +double_quoted = '"' { DQ_CHAR | escape | interpolation } '"' +single_quoted = "'" { SQ_CHAR } "'" +unquoted = { UQ_CHAR | interpolation } [ WS "#" TEXT ] +escape = "\" ( "n" | "r" | "t" | '"' | "\" | "$" | ANY ) +interpolation = "$" NAME | "${" NAME [ ( ":-" | ":+" ) OPERAND ] "}" +``` diff --git a/docs/spec/SPEC-002-type-system.md b/docs/spec/SPEC-002-type-system.md new file mode 100644 index 0000000..e834190 --- /dev/null +++ b/docs/spec/SPEC-002-type-system.md @@ -0,0 +1,185 @@ +# SPEC-002: Type System + +**Version:** 1.0 +**Date:** 2024-01-20 +**Applies to:** KaririCode\Dotenv 4.0+ + +## 1. Overview + +The Type System performs automatic detection and casting of environment variable values from their raw string representation into typed PHP values. It operates in two phases: detection (string → ValueType) and casting (string × ValueType → mixed). + +## 2. Value Types + +```php +enum ValueType { + case String; // Default fallback + case Integer; // Whole numbers with optional sign + case Float; // Decimal numbers and scientific notation + case Boolean; // true/false, yes/no, on/off + case Null; // null, NULL, (null) + case Json; // JSON objects: {...} + case Array; // JSON arrays: [...] +} +``` + +## 3. Detection Rules + +Detectors execute in priority order (highest first). The first non-null result wins. + +### 3.1 Null (Priority 200) + +Matches exactly: `"null"`, `"NULL"`, `"(null)"`. + +Empty string (`""`) is **not** null — it is `ValueType::String`. This distinction is intentional: `FOO=` (empty string) and `FOO=null` (explicit null) carry different semantics. + +### 3.2 Boolean (Priority 190) + +Case-insensitive match against: + +| Truthy | Falsy | +|---|---| +| `true`, `TRUE`, `True` | `false`, `FALSE`, `False` | +| `yes`, `YES`, `Yes` | `no`, `NO`, `No` | +| `on`, `ON`, `On` | `off`, `OFF`, `Off` | +| `(true)` | `(false)` | + +### 3.3 Integer (Priority 180) + +Pattern: `/\A[+-]?\d+\z/` + +Matches: `42`, `-10`, `+99`, `0`, `00042` +Does not match: `""`, `"-"`, `"+"`, `"3.14"`, `"1e10"` + +### 3.4 Float (Priority 170) + +Pattern: `/\A[+-]?(\d+\.?\d*|\d*\.?\d+)([eE][+-]?\d+)?\z/` + +**Prerequisite:** The value must contain a decimal point (`.`) or an exponent marker (`e`/`E`). This prevents `"42"` from being detected as float. + +Matches: `3.14`, `-0.5`, `1e10`, `2.5E-3`, `.5`, `3.` +Does not match: `42` (no dot or exponent — detected as integer) + +### 3.5 JSON Object (Priority 160) + +Conditions: +1. Trimmed value starts with `{` and ends with `}`. +2. `json_decode()` succeeds (`json_last_error() === JSON_ERROR_NONE`). + +Matches: `{"key": "value"}`, `{"nested": {"a": 1}}` +Does not match: `{invalid json}`, `{` (unclosed) + +### 3.6 JSON Array (Priority 150) + +Conditions: +1. Trimmed value starts with `[` and ends with `]`. +2. `json_decode(..., true)` returns a PHP array. + +Matches: `["a", "b"]`, `[1, 2, 3]` +Does not match: `[invalid]`, `[` (unclosed) + +### 3.7 String (Fallback) + +If no detector matches, the value is `ValueType::String`. No casting is applied. + +## 4. Casting Rules + +### 4.1 Null → `null` + +Always returns PHP `null`. + +### 4.2 Boolean → `bool` + +Truthy values: `true`, `yes`, `on`, `(true)` (case-insensitive) → `true` +Everything else in the boolean detection set → `false` + +### 4.3 Integer → `int` + +Direct cast: `(int) $value` + +### 4.4 Float → `float` + +Direct cast: `(float) $value` + +### 4.5 JSON Object → `array` + +`json_decode($value, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR)` + +Returns an associative array. Throws `\JsonException` on malformed input (fail-fast). + +### 4.6 JSON Array → `array` + +Same as JSON Object — `json_decode()` with `associative: true`. + +### 4.7 String → `string` + +Identity function — value returned as-is. + +## 5. Extension API + +### 5.1 Custom Detector + +```php +use KaririCode\Dotenv\Contract\TypeDetector; +use KaririCode\Dotenv\Enum\ValueType; + +final readonly class UuidDetector implements TypeDetector +{ + public function priority(): int { return 195; } // Between Null and Boolean + + public function detect(string $value): ?ValueType + { + return preg_match('/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i', $value) === 1 + ? ValueType::String // Detected as UUID, but cast as string + : null; + } +} + +$dotenv->addTypeDetector(new UuidDetector()); +``` + +### 5.2 Custom Caster + +```php +use KaririCode\Dotenv\Contract\TypeCaster; + +$dotenv->addTypeCaster(ValueType::Integer, new class implements TypeCaster { + public function cast(string $value): int + { + return abs((int) $value); // Always positive + } +}); +``` + +Custom casters replace the default for the given `ValueType`. + +### 5.3 Priority Guidelines + +| Range | Reserved For | +|---|---| +| 200–250 | Null-like types (highest precedence) | +| 150–199 | Scalar types (boolean, integer, float) | +| 100–149 | Structured types (JSON, arrays) | +| 50–99 | Application-specific detectors | +| 1–49 | Low-priority catch-alls | + +## 6. Disable Type Casting + +```php +$config = new DotenvConfiguration(typeCasting: false); +``` + +When disabled: +- All values remain `ValueType::String`. +- No detector or caster is invoked. +- `env()` returns raw strings. +- `EnvironmentVariable::$type` is always `ValueType::String`. + +## 7. The `env()` Helper + +The global `env()` function resolves variables from `$_ENV` → `$_SERVER` → `getenv()` and applies the default `TypeSystem` pipeline. It maintains a static `TypeSystem` instance for the lifetime of the request. + +```php +function env(string $key, mixed $default = null): mixed +``` + +If the variable is not found in any source, `$default` is returned without type casting. diff --git a/docs/spec/SPEC-003-encryption.md b/docs/spec/SPEC-003-encryption.md new file mode 100644 index 0000000..667b9ef --- /dev/null +++ b/docs/spec/SPEC-003-encryption.md @@ -0,0 +1,213 @@ +# SPEC-003: Encryption + +**Version:** 1.0 +**Date:** 2024-02-10 +**Applies to:** KaririCode\Dotenv 4.3+ + +## 1. Overview + +KaririCode\Dotenv supports per-value AES-256-GCM encryption, allowing secrets to be committed to version control while remaining confidential. Decryption is transparent during `load()` — no application code changes are required. + +## 2. Prerequisites + +- PHP extension: ext-openssl (bundled in standard PHP distributions) +- A 256-bit encryption key (generated via `KeyPair::generate()` or the `keygen` CLI command) + +## 3. Key Management + +### 3.1 Key Generation + +```php +use KaririCode\Dotenv\Security\KeyPair; + +$keyPair = KeyPair::generate(); +echo $keyPair->privateKey; // 64-char hex string (256-bit key) +echo $keyPair->publicId; // 8-char identifier (first 8 chars of SHA-256 hash) +``` + +CLI equivalent: + +```bash +vendor/bin/kariricode-dotenv keygen +``` + +### 3.2 Key Format + +| Representation | Length | Example | +|---|---|---| +| Hex string | 64 characters | `a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2` | +| Raw binary | 32 bytes | *(binary)* | + +The `Encryptor` constructor accepts both formats. Hex strings are auto-detected (64 chars, all hex digits) and decoded to binary internally. + +### 3.3 Key Storage + +The private key must **never** be committed to version control. Recommended storage: + +1. **Environment variable:** `DOTENV_PRIVATE_KEY` (set by the orchestrator) +2. **CI/CD secret:** Injected during deployment +3. **Secret manager:** Retrieved at application bootstrap + +### 3.4 Key Reconstitution + +```php +$keyPair = KeyPair::fromPrivateKey($hexKey); +// Derives the same publicId from the key — useful for verification +``` + +### 3.5 Public ID + +The 8-character public ID is a non-secret identifier derived from `sha256(raw_key)[0:8]`. It allows referencing keys in multi-environment setups without exposing the key material: + +``` +production: key_id=f7e8d9c0 +staging: key_id=a1b2c3d4 +``` + +## 4. Wire Format + +### 4.1 Encrypted Value + +``` +encrypted: +``` + +### 4.2 Binary Layout + +``` +Offset Length Field +0 12 Nonce (random IV) +12 N Ciphertext (AES-256-GCM output, N ≥ 0) +12+N 16 Authentication Tag (GCM MAC) +``` + +Total binary size: `28 + plaintext_length` bytes (before base64 encoding). + +### 4.3 Detection + +A value is considered encrypted if and only if it starts with the ASCII prefix `encrypted:`: + +```php +Encryptor::isEncrypted($value); // str_starts_with($value, 'encrypted:') +``` + +## 5. Encryption Process + +``` +Input: plaintext string +Output: "encrypted:" + base64(nonce + ciphertext + tag) + +1. Generate 12 random bytes (nonce) via random_bytes(12) +2. Encrypt using openssl_encrypt(): + - Algorithm: aes-256-gcm + - Key: 32-byte binary key + - IV: nonce (12 bytes) + - Options: OPENSSL_RAW_DATA + - Tag output: 16 bytes +3. Concatenate: nonce + ciphertext + tag +4. Base64-encode the concatenation +5. Prepend "encrypted:" prefix +``` + +## 6. Decryption Process + +``` +Input: "encrypted:" + base64_payload +Output: plaintext string + +1. Verify prefix "encrypted:" — if absent, return value unchanged (passthrough) +2. Strip prefix, base64_decode the remainder +3. Validate minimum length: ≥ 28 bytes (12 nonce + 0 ciphertext + 16 tag) +4. Extract: + - nonce: bytes[0..11] + - tag: bytes[-16..] + - ciphertext: bytes[12..-16] +5. Decrypt using openssl_decrypt(): + - Algorithm: aes-256-gcm + - Key: 32-byte binary key + - IV: nonce + - Options: OPENSSL_RAW_DATA + - Tag: 16-byte tag +6. If openssl_decrypt returns false → throw RuntimeException +7. Return plaintext +``` + +## 7. Integration with Load Pipeline + +Decryption occurs inside `Dotenv::setVariable()`, after allow/deny filtering and before type detection: + +``` +Raw value from parser + → Allow/deny check + → Encryption detection (str_starts_with 'encrypted:') + → Yes: decrypt → decrypted string + → No: passthrough + → Type detection → Type casting → Processor application → Store +``` + +The decrypted value is stored in `EnvironmentVariable::$rawValue` and populated into `$_ENV`/`$_SERVER`. The original encrypted string is never stored in application-accessible state. + +## 8. Configuration + +### 8.1 Via DotenvConfiguration + +```php +$config = new DotenvConfiguration( + encryptionKey: 'a1b2c3d4...', // 64-char hex +); +``` + +### 8.2 Via Environment Variable + +If `DotenvConfiguration::$encryptionKey` is null, the Dotenv constructor checks: +1. `$_SERVER['DOTENV_PRIVATE_KEY']` +2. `$_ENV['DOTENV_PRIVATE_KEY']` + +This allows setting the key once in the container orchestrator. + +### 8.3 No Key Configured + +If no encryption key is available, encrypted values remain as-is (the literal `encrypted:...` string). No error is thrown — this allows committed `.env` files with encrypted values to be loaded in environments where decryption is not needed (e.g., build systems that only need non-secret variables). + +## 9. Security Properties + +| Property | Guarantee | +|---|---| +| Confidentiality | AES-256 encryption (256-bit key space) | +| Integrity | GCM authentication tag (128-bit MAC) | +| Nonce uniqueness | 96-bit random nonce per encryption (2⁹⁶ space) | +| No padding oracle | GCM is a streaming mode — no padding | +| Key validation | Rejects keys ≠ 32 bytes at construction time | +| Timing safety | `openssl_decrypt()` uses constant-time tag comparison internally | + +## 10. Error Conditions + +| Condition | Exception | Message | +|---|---|---| +| Key not 32 bytes | `InvalidArgumentException` | "Encryption key must be 32 bytes (256-bit) or 64-char hex string." | +| `openssl_encrypt()` fails | `RuntimeException` | "Encryption failed: {openssl_error_string}" | +| Invalid base64 payload | `RuntimeException` | "Invalid encrypted payload: malformed base64 or too short." | +| Decryption failure (wrong key, corruption) | `RuntimeException` | "Decryption failed — wrong key or corrupted payload." | + +## 11. CLI Commands + +### 11.1 `keygen` + +Generates a new key pair and prints both values. + +### 11.2 `encrypt` + +Encrypts all plaintext values in a `.env` file, writing the result to the same file or a specified output path. + +```bash +vendor/bin/kariricode-dotenv encrypt .env --key=a1b2c3d4... +vendor/bin/kariricode-dotenv encrypt .env --key=a1b2c3d4... --output=.env.encrypted +``` + +### 11.3 `decrypt` + +Decrypts all encrypted values, producing a plaintext `.env` file. + +```bash +vendor/bin/kariricode-dotenv decrypt .env --key=a1b2c3d4... +``` diff --git a/docs/spec/SPEC-004-validation.md b/docs/spec/SPEC-004-validation.md new file mode 100644 index 0000000..f102175 --- /dev/null +++ b/docs/spec/SPEC-004-validation.md @@ -0,0 +1,234 @@ +# SPEC-004: Validation + +**Version:** 1.0 +**Date:** 2024-02-05 +**Applies to:** KaririCode\Dotenv 4.1+ + +## 1. Overview + +KaririCode\Dotenv provides two validation mechanisms: + +1. **Fluent DSL** — Programmatic rule definition via `EnvironmentValidator`. +2. **Schema file** — Declarative rule definition via `.env.schema` (see SPEC-005). + +Both mechanisms collect all failures before throwing a single `ValidationException`. + +## 2. Entry Points + +### 2.1 Simple Required Check + +```php +$dotenv->required('DB_HOST', 'DB_PORT', 'APP_ENV'); +``` + +Throws `ValidationException` listing all missing variables. + +### 2.2 Fluent Validator + +```php +$dotenv->validate() // Returns EnvironmentValidator + ->required(...) + ->isInteger(...) + ->assert(); // Executes all rules, throws on failure +``` + +### 2.3 Schema Validation + +```php +$dotenv->loadWithSchema(__DIR__ . '/.env.schema'); +``` + +Parses the schema, loads `.env` if not already loaded, applies rules, and asserts. + +## 3. EnvironmentValidator API + +### 3.1 Targeting Methods + +These methods set the current target variables for subsequent rule methods. + +| Method | Behavior | +|---|---| +| `required(string ...$names): self` | Marks variables as required (must exist). Sets targets. | +| `ifPresent(string ...$names): self` | Sets targets. Rules only apply if variable exists. | + +### 3.2 Rule Methods + +Rule methods apply their rule to the **current targets** (set by the most recent targeting method or rule method that accepts names). + +| Method | Rule Applied | Target Behavior | +|---|---|---| +| `notEmpty(string ...$names)` | `NotEmptyRule` | If `$names` given, sets new targets; otherwise uses current. | +| `isInteger(string ...$names)` | `IsIntegerRule` | Same. | +| `isBoolean(string ...$names)` | `IsBooleanRule` | Same. | +| `isNumeric(string ...$names)` | `IsNumericRule` | Same. | +| `between(int\|float $min, int\|float $max)` | `BetweenRule` | Always uses current targets (chainable). | +| `allowedValues(string $name, array $allowed)` | `AllowedValuesRule` | Sets target to `[$name]`. | +| `matchesRegex(string $name, string $pattern)` | `MatchesRegexRule` | Sets target to `[$name]`. | +| `url(string ...$names)` | `UrlRule` | If `$names` given, sets new targets. | +| `email(string ...$names)` | `EmailRule` | Same. | +| `custom(string $name, Closure $callback, string $message)` | `CustomRule` | Sets target to `[$name]`. | +| `rule(ValidationRule $rule, string ...$names)` | Arbitrary rule | If `$names` given, sets new targets. | + +### 3.3 Chaining + +Methods that inherit current targets enable natural chains: + +```php +->isInteger('DB_PORT')->between(1, 65535) +// isInteger sets target to ['DB_PORT'] +// between inherits target ['DB_PORT'] +``` + +### 3.4 Execution + +```php +public function assert(): void +``` + +1. Checks all `required` variables for presence. +2. Iterates all variables with registered rules. +3. Skips absent variables in conditional mode (`ifPresent`). +4. Skips absent required variables from rule execution (already reported as missing). +5. Collects all failure messages. +6. Throws `ValidationException::batchErrors($errors)` if any failures exist. + +## 4. Built-in Rules + +### 4.1 NotEmptyRule + +```php +trim($value) !== '' +``` + +Message: `"{name} must not be empty."` + +### 4.2 IsIntegerRule + +```php +preg_match('/\A[+-]?\d+\z/', $value) === 1 +``` + +Message: `"{name} must be an integer."` + +### 4.3 IsBooleanRule + +Accepted values (case-insensitive): `true`, `false`, `1`, `0`, `yes`, `no`, `on`, `off`. + +Message: `"{name} must be a boolean (true/false, yes/no, on/off, 1/0)."` + +### 4.4 IsNumericRule + +```php +is_numeric($value) +``` + +Message: `"{name} must be numeric."` + +### 4.5 BetweenRule + +```php +is_numeric($value) && ($value + 0) >= $min && ($value + 0) <= $max +``` + +Message: `"{name} must be between {min} and {max}."` + +### 4.6 AllowedValuesRule + +```php +in_array($value, $allowed, strict: true) +``` + +Message: `"{name} must be one of: {comma-separated list}."` + +### 4.7 MatchesRegexRule + +```php +preg_match($pattern, $value) === 1 +``` + +Message: `"{name} must match pattern {pattern}."` + +### 4.8 UrlRule + +```php +filter_var($value, FILTER_VALIDATE_URL) !== false +``` + +Message: `"{name} must be a valid URL."` + +### 4.9 EmailRule + +```php +filter_var($value, FILTER_VALIDATE_EMAIL) !== false +``` + +Message: `"{name} must be a valid email address."` + +### 4.10 CustomRule + +User-provided `Closure(string): bool` with custom message. + +Message: User-defined (default: `"{name} failed custom validation."`) + +## 5. Custom ValidationRule Contract + +```php +interface ValidationRule +{ + public function passes(string $value): bool; + public function message(): string; +} +``` + +The `{name}` placeholder in `message()` is replaced with the variable name at assertion time. + +```php +final readonly class MinLengthRule implements ValidationRule +{ + public function __construct(private int $minLength) {} + + public function passes(string $value): bool + { + return strlen($value) >= $this->minLength; + } + + public function message(): string + { + return "{name} must be at least {$this->minLength} characters."; + } +} + +$dotenv->validate() + ->rule(new MinLengthRule(32), 'API_KEY') + ->assert(); +``` + +## 6. ValidationException + +```php +final class ValidationException extends DotenvException +{ + public static function missingRequired(array $missing): self; + public static function batchErrors(array $errors): self; + public static function schemaViolation(string $message): self; + + /** @return list All failure messages. */ + public function errors(): array; +} +``` + +The `errors()` method returns a flat list of failure messages, suitable for display in logs or CLI output. + +## 7. Value Resolution + +The `EnvironmentValidator` receives a `Closure(string): ?string` that resolves variable names to raw string values. This decouples validation from the storage mechanism: + +```php +new EnvironmentValidator( + fn (string $name): ?string => $this->resolveRawValue($name), +); +``` + +Resolution order inside `resolveRawValue()`: `$this->variables[$name]->rawValue` → `$_ENV[$name]` → `$_SERVER[$name]` → `null`. + +Validation operates on **raw string values**, not typed values. This ensures that `isInteger('DB_PORT')` validates the string `"5432"`, not the integer `5432`. diff --git a/docs/spec/SPEC-005-schema-format.md b/docs/spec/SPEC-005-schema-format.md new file mode 100644 index 0000000..a1ab8de --- /dev/null +++ b/docs/spec/SPEC-005-schema-format.md @@ -0,0 +1,243 @@ +# SPEC-005: Schema Format (.env.schema) + +**Version:** 1.0 +**Date:** 2024-03-01 +**Applies to:** KaririCode\Dotenv 4.4+ + +## 1. Overview + +The `.env.schema` file provides declarative validation rules for environment variables using an INI-like syntax. It eliminates the need for programmatic validation code and serves as living documentation of the application's environment contract. + +## 2. File Format + +### 2.1 Encoding + +UTF-8. Line endings: LF or CRLF (normalized to LF internally). + +### 2.2 Structure + +```ini +# Comments start with # or ; +; INI-style comments also supported + +[VARIABLE_NAME] +directive = value +directive = value + +[ANOTHER_VARIABLE] +directive = value +``` + +### 2.3 Section Headers + +``` +[NAME] +``` + +Pattern: `[A-Za-z_][A-Za-z0-9_]*` enclosed in square brackets. Each section defines rules for one environment variable. + +### 2.4 Directives + +``` +key = value +``` + +The `=` separator is required. Leading and trailing whitespace around both key and value is trimmed. + +### 2.5 Comments and Empty Lines + +Lines starting with `#` or `;` (after optional whitespace) are ignored. Empty lines are ignored. + +## 3. Supported Directives + +### 3.1 `required` + +**Values:** `true`, `false`, `1`, `0`, `yes`, `no`, `on`, `off` (case-insensitive) +**Default:** `false` + +When `true`, the variable must exist (non-null). Missing required variables are reported as errors. + +```ini +[DB_HOST] +required = true +``` + +### 3.2 `type` + +**Values:** `string`, `integer`, `boolean`, `numeric`, `email`, `url` + +Applies the corresponding type validation rule. + +| Type | Validator Rule | Description | +|---|---|---| +| `string` | *(none)* | No additional validation — all values are strings. | +| `integer` | `IsIntegerRule` | Must match `/\A[+-]?\d+\z/`. | +| `boolean` | `IsBooleanRule` | Must be true/false/1/0/yes/no/on/off. | +| `numeric` | `IsNumericRule` | Must pass `is_numeric()`. | +| `email` | `EmailRule` | Must pass `FILTER_VALIDATE_EMAIL`. | +| `url` | `UrlRule` | Must pass `FILTER_VALIDATE_URL`. | + +Unknown types throw `ValidationException::schemaViolation()` at schema application time. + +```ini +[DB_PORT] +type = integer +``` + +### 3.3 `notEmpty` + +**Values:** Boolean (same as `required`) + +When `true`, the variable's trimmed value must not be an empty string. + +```ini +[DB_HOST] +required = true +notEmpty = true +``` + +### 3.4 `min` / `max` + +**Values:** Numeric (integer or float) + +Defines a numeric range. Both `min` and `max` must be present for the range check to apply. The underlying rule is `BetweenRule`, which requires the value to be numeric. + +```ini +[DB_PORT] +type = integer +min = 1 +max = 65535 +``` + +If only `min` or only `max` is specified, the range check is not applied. This prevents ambiguous open-ended ranges. + +### 3.5 `allowed` + +**Values:** Comma-separated list of acceptable values. Whitespace around each value is trimmed. + +```ini +[APP_ENV] +allowed = local, staging, production +``` + +Produces: `AllowedValuesRule(['local', 'staging', 'production'])`. + +### 3.6 `regex` + +**Values:** A PCRE pattern including delimiters. + +```ini +[API_KEY] +regex = /^[a-f0-9]{32}$/ +``` + +Produces: `MatchesRegexRule('/^[a-f0-9]{32}$/')`. + +### 3.7 `default` + +**Values:** Any string. + +Informational only — documents the default value but does **not** inject it into the environment. Default injection is out of scope for the schema parser; the `.env` file should contain the actual default. + +```ini +[DB_PORT] +default = 5432 +``` + +## 4. Processing Order + +For each variable section, directives are applied in this order: + +1. **required / optional** — Register as required or conditional (`ifPresent`). +2. **notEmpty** — Add `NotEmptyRule` if `true`. +3. **type** — Add the corresponding type rule. +4. **min + max** — Add `BetweenRule` if both are present. +5. **allowed** — Add `AllowedValuesRule`. +6. **regex** — Add `MatchesRegexRule`. + +This order ensures that presence is checked before content, and type is validated before range. + +## 5. Interaction with Fluent DSL + +Schema validation and fluent DSL validation are not mutually exclusive. Both can be used in the same application: + +```php +$dotenv->loadWithSchema(__DIR__ . '/.env.schema'); // Declarative rules +$dotenv->validate() + ->custom('DB_DSN', fn($v) => str_starts_with($v, 'pgsql:')) + ->assert(); // Additional programmatic rules +``` + +## 6. Full Example + +```ini +# .env.schema — Application Environment Contract +# Generated: 2024-03-01 + +[APP_ENV] +required = true +allowed = local, staging, production + +[APP_DEBUG] +required = true +type = boolean + +[APP_URL] +required = true +type = url +notEmpty = true + +[DB_HOST] +required = true +notEmpty = true + +[DB_PORT] +required = true +type = integer +min = 1 +max = 65535 +default = 5432 + +[DB_NAME] +required = true +notEmpty = true + +[DB_USER] +required = true +notEmpty = true + +[DB_PASSWORD] +required = true + +[REDIS_HOST] +required = false +type = string + +[REDIS_PORT] +required = false +type = integer +min = 1 +max = 65535 + +[ADMIN_EMAIL] +type = email + +[API_KEY] +regex = /^[a-f0-9]{32}$/ + +[LOG_LEVEL] +allowed = debug, info, warning, error, critical +default = info +``` + +## 7. Error Reporting + +Schema validation failures are reported through the same `ValidationException::batchErrors()` mechanism as fluent validation. All failures across all variables are collected before throwing. + +``` +Environment validation failed: +- DB_HOST is required but not defined. +- DB_PORT must be an integer. +- APP_ENV must be one of: local, staging, production. +- API_KEY must match pattern /^[a-f0-9]{32}$/. +``` diff --git a/docs/spec/SPEC-006-processors.md b/docs/spec/SPEC-006-processors.md new file mode 100644 index 0000000..908e8fe --- /dev/null +++ b/docs/spec/SPEC-006-processors.md @@ -0,0 +1,212 @@ +# SPEC-006: Variable Processors + +**Version:** 1.0 +**Date:** 2024-03-01 +**Applies to:** KaririCode\Dotenv 4.4+ + +## 1. Overview + +Variable processors are post-load transformers that modify environment variable values after parsing and type casting. They enable domain-specific transformations (splitting CSV into arrays, normalizing URLs, decoding base64) without polluting the parser or type system. + +## 2. Contract + +```php +interface VariableProcessor +{ + public function process(string $rawValue, mixed $typedValue): mixed; +} +``` + +| Parameter | Description | +|---|---| +| `$rawValue` | The raw string from the `.env` file (after decryption, before type casting). | +| `$typedValue` | The value after type detection and casting. | +| **Returns** | The transformed value that replaces `$typedValue` in the `EnvironmentVariable`. | + +Processors receive both the raw and typed values, allowing them to choose which representation to work with. Most processors operate on `$rawValue` (string manipulation), but pipeline processors may chain off `$typedValue`. + +## 3. Registration + +```php +$dotenv->addProcessor(string $pattern, VariableProcessor $processor): void +``` + +| Parameter | Description | +|---|---| +| `$pattern` | Exact variable name or glob pattern. | +| `$processor` | Instance implementing `VariableProcessor`. | + +Multiple processors can be registered for the same pattern. They execute in registration order. + +### 3.1 Glob Patterns + +| Pattern | Matches | Does Not Match | +|---|---|---| +| `ALLOWED_IPS` | `ALLOWED_IPS` | `ALLOWED_IPS_V6` | +| `*_URL` | `APP_URL`, `API_URL`, `WEBHOOK_URL` | `URL_PREFIX` | +| `DB_*` | `DB_HOST`, `DB_PORT`, `DB_NAME` | `REDIS_HOST` | +| `*_SECRET*` | `DB_SECRET`, `API_SECRET_KEY` | `SECRET` (no prefix) | + +Glob matching uses `*` (any characters) and `?` (single character). The implementation converts globs to PCRE: + +```php +$regex = '/\A' . str_replace(['\*', '\?'], ['.*', '.'], preg_quote($pattern, '/')) . '\z/'; +``` + +## 4. Execution Order + +Processors execute within `Dotenv::setVariable()`, after type casting and before storage: + +``` +Raw value → Decryption → Type detection → Type casting → Processor(s) → EnvironmentVariable +``` + +For a given variable, processors are applied in pattern registration order: + +```php +$dotenv->addProcessor('*_URL', new TrimProcessor()); // Runs first +$dotenv->addProcessor('*_URL', new UrlNormalizerProcessor()); // Runs second +``` + +If multiple patterns match the same variable, all matching processors run in registration order across all patterns. + +## 5. Built-in Processors + +### 5.1 CsvToArrayProcessor + +Splits a comma-delimited string into an array of trimmed values. + +```php +final readonly class CsvToArrayProcessor implements VariableProcessor +{ + public function __construct(private string $separator = ',') {} + + public function process(string $rawValue, mixed $typedValue): array + { + if (trim($rawValue) === '') { + return []; + } + return array_map(trim(...), explode($this->separator, $rawValue)); + } +} +``` + +| Input | Output | +|---|---| +| `"192.168.1.1, 10.0.0.1, 172.16.0.1"` | `['192.168.1.1', '10.0.0.1', '172.16.0.1']` | +| `""` | `[]` | +| `" "` | `[]` | + +Custom separator: + +```php +new CsvToArrayProcessor(separator: '|') // "a|b|c" → ["a", "b", "c"] +``` + +### 5.2 Base64DecodeProcessor + +Decodes a base64-encoded raw value. Throws on invalid input. + +```php +final readonly class Base64DecodeProcessor implements VariableProcessor +{ + public function process(string $rawValue, mixed $typedValue): string + { + $decoded = base64_decode($rawValue, strict: true); + if ($decoded === false) { + throw new \RuntimeException("Invalid base64 value: cannot decode."); + } + return $decoded; + } +} +``` + +Use case: certificates, binary keys, or opaque tokens stored as base64 in `.env`. + +### 5.3 TrimProcessor + +Trims whitespace (or custom characters) from the raw value. + +```php +final readonly class TrimProcessor implements VariableProcessor +{ + public function __construct(private string $characters = " \t\n\r\0\x0B") {} + + public function process(string $rawValue, mixed $typedValue): string + { + return trim($rawValue, $this->characters); + } +} +``` + +### 5.4 UrlNormalizerProcessor + +Ensures URLs end with a trailing slash. Removes duplicate trailing slashes. + +```php +final readonly class UrlNormalizerProcessor implements VariableProcessor +{ + public function process(string $rawValue, mixed $typedValue): string + { + return rtrim($rawValue, '/') . '/'; + } +} +``` + +| Input | Output | +|---|---| +| `"https://api.example.com"` | `"https://api.example.com/"` | +| `"https://api.example.com/"` | `"https://api.example.com/"` | +| `"https://api.example.com//"` | `"https://api.example.com/"` | + +## 6. Custom Processors + +Implement `VariableProcessor` for application-specific transformations: + +```php +final readonly class JsonDecodeProcessor implements VariableProcessor +{ + public function process(string $rawValue, mixed $typedValue): array + { + return json_decode($rawValue, true, 512, JSON_THROW_ON_ERROR); + } +} + +final readonly class UpperCaseProcessor implements VariableProcessor +{ + public function process(string $rawValue, mixed $typedValue): string + { + return strtoupper($rawValue); + } +} + +final readonly class PrefixProcessor implements VariableProcessor +{ + public function __construct(private string $prefix) {} + + public function process(string $rawValue, mixed $typedValue): string + { + return $this->prefix . $rawValue; + } +} +``` + +## 7. Interaction with Type Casting + +Processors run **after** type casting. The processor's return value replaces the typed value in the `EnvironmentVariable`: + +```php +// .env: ALLOWED_IPS=192.168.1.1, 10.0.0.1 +// Without processor: type=String, value="192.168.1.1, 10.0.0.1" +// With CsvToArrayProcessor: type=String, value=["192.168.1.1", "10.0.0.1"] +``` + +Note that `EnvironmentVariable::$type` reflects the **original** detection (String), not the processor's output type. The `$value` field holds the processor's output. + +## 8. Interaction with Caching + +Processors are **not** invoked when loading from cache. The cache stores raw string values, and type casting + processors run on every load (cache or file). This ensures that processor registration at runtime is always respected. + +## 9. Error Handling + +Processor exceptions propagate uncaught from `Dotenv::load()`. This is intentional — a processor failure (e.g., invalid base64) indicates a configuration error that should halt application startup. diff --git a/docs/spec/SPEC-007-cli.md b/docs/spec/SPEC-007-cli.md new file mode 100644 index 0000000..693e8c9 --- /dev/null +++ b/docs/spec/SPEC-007-cli.md @@ -0,0 +1,253 @@ +# SPEC-007: CLI Tooling + +**Version:** 1.0 +**Date:** 2024-03-01 +**Applies to:** KaririCode\Dotenv 4.4+ + +## 1. Overview + +KaririCode\Dotenv ships a CLI tool at `vendor/bin/kariricode-dotenv` (registered in `composer.json` `bin` field). It provides 9 commands for managing `.env` files without writing PHP code. + +## 2. Installation + +The CLI is automatically available after `composer install`/`require`. No additional setup needed. + +```bash +vendor/bin/kariricode-dotenv help +``` + +## 3. Global Options + +| Option | Default | Description | +|---|---|---| +| `--dir=` | Current working directory | Project root directory containing `.env` files. | + +## 4. Commands + +### 4.1 `debug` + +Lists all loaded variables with their detected types, source files, and override status. + +```bash +vendor/bin/kariricode-dotenv debug [--dir=.] +``` + +**Output format:** + +``` +Variable Source Type Overridden Value +────────────────────────────────────────────────────────── +DB_HOST .env.local String yes localhost +DB_PORT .env Integer no 5432 +APP_DEBUG .env Boolean no true +APP_ENV .env.staging String yes staging +``` + +**Process:** Instantiates `Dotenv`, calls `bootEnv()`, then formats `debug()` output as a table. + +### 4.2 `validate` + +Validates the current `.env` configuration against a `.env.schema` file. + +```bash +vendor/bin/kariricode-dotenv validate [--dir=.] [--schema=.env.schema] +``` + +| Option | Default | Description | +|---|---|---| +| `--schema=` | `.env.schema` (relative to `--dir`) | Path to the schema file. | + +**Exit codes:** +- `0` — All validations passed. +- `1` — One or more validation failures. Errors printed to STDERR. + +**Output on failure:** + +``` +✗ Environment validation failed: + - DB_HOST is required but not defined. + - DB_PORT must be an integer. + - APP_ENV must be one of: local, staging, production. +``` + +### 4.3 `encrypt` + +Encrypts all plaintext values in a `.env` file using AES-256-GCM. + +```bash +vendor/bin/kariricode-dotenv encrypt --key= [--output=] +``` + +| Option | Required | Description | +|---|---|---| +| `` | Yes | Path to the `.env` file to encrypt. | +| `--key=` | Yes | 64-character hex encryption key. | +| `--output=` | No | Output path. If omitted, overwrites the input file. | + +**Behavior:** +1. Parses the input file line by line. +2. For each `KEY=VALUE` line where the value does not already start with `encrypted:`, encrypts the value. +3. Preserves comments, empty lines, and formatting. +4. Already-encrypted values are left unchanged (idempotent). + +**Output:** + +``` +Encrypted 5 values in .env + DB_PASSWORD: encrypted + API_SECRET: encrypted + SMTP_PASSWORD: encrypted + JWT_KEY: encrypted + REDIS_PASSWORD: encrypted +Skipped 8 plaintext values (not encrypted) +Skipped 2 already-encrypted values +``` + +### 4.4 `decrypt` + +Decrypts all encrypted values in a `.env` file, producing a plaintext version. + +```bash +vendor/bin/kariricode-dotenv decrypt --key= [--output=] +``` + +Same options and behavior as `encrypt`, but in reverse. Plaintext values pass through unchanged. + +### 4.5 `cache:dump` + +Generates an OPcache-friendly PHP cache file from the current `.env` configuration. + +```bash +vendor/bin/kariricode-dotenv cache:dump [--dir=.] [--output=.env.cache.php] +``` + +| Option | Default | Description | +|---|---|---| +| `--output=` | `.env.cache.php` (relative to `--dir`) | Cache file path. | + +**Process:** Loads `.env` via `bootEnv()`, then calls `dumpCache()`. + +### 4.6 `cache:clear` + +Removes the PHP cache file. + +```bash +vendor/bin/kariricode-dotenv cache:clear [--dir=.] [--file=.env.cache.php] +``` + +### 4.7 `diff` + +Compares two `.env` files, showing added, removed, and changed variables. + +```bash +vendor/bin/kariricode-dotenv diff +``` + +**Output:** + +``` +Comparing .env vs .env.production: + + + REDIS_HOST=redis.internal (added in .env.production) + - DEV_TOOLBAR=true (removed in .env.production) + ~ DB_HOST: localhost → prod-db.internal + ~ APP_DEBUG: true → false + +Summary: 2 changed, 1 added, 1 removed, 8 unchanged +``` + +### 4.8 `example:generate` + +Generates a `.env.example` file from an existing `.env` by stripping all values. + +```bash +vendor/bin/kariricode-dotenv example:generate [--dir=.] [--output=.env.example] +``` + +**Output file:** + +```ini +# Auto-generated from .env — do not edit manually. +# Copy to .env and fill in the values. + +DB_HOST= +DB_PORT= +DB_NAME= +DB_USER= +DB_PASSWORD= +APP_ENV= +APP_DEBUG= +APP_URL= +``` + +Comments from the source file are preserved. Values are stripped to empty strings. + +### 4.9 `keygen` + +Generates a new encryption key pair. + +```bash +vendor/bin/kariricode-dotenv keygen +``` + +**Output:** + +``` +KaririCode\Dotenv — Key Generation + + Private key: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2 + Public ID: f7e8d9c0 + + Store the private key in DOTENV_PRIVATE_KEY environment variable. + Never commit the private key to version control. + + Usage: + export DOTENV_PRIVATE_KEY=a1b2c3d4... + vendor/bin/kariricode-dotenv encrypt .env --key=a1b2c3d4... +``` + +## 5. Exit Codes + +| Code | Meaning | +|---|---| +| `0` | Success | +| `1` | Validation failure or expected error (missing file, wrong key) | +| `2` | Usage error (unknown command, missing required option) | + +## 6. Error Output + +All errors are written to STDERR. Normal output goes to STDOUT. This allows piping: + +```bash +vendor/bin/kariricode-dotenv debug 2>/dev/null | grep DB_ +``` + +## 7. Autoload Resolution + +The CLI resolves `vendor/autoload.php` by checking two paths: +1. `__DIR__ . '/../vendor/autoload.php'` — when installed as a root dependency. +2. `__DIR__ . '/../../../autoload.php'` — when installed as a dependency of another package. + +If neither path exists, the CLI prints an error to STDERR and exits with code 1. + +## 8. Option Parsing + +Options are parsed from `$argv` with a simple `--key=value` convention. No external option parser is used (zero dependencies). Boolean flags use `--flag` (no value). + +```php +function parseOptions(array $args): array +{ + $options = []; + foreach ($args as $arg) { + if (str_starts_with($arg, '--')) { + $eqPos = strpos($arg, '='); + if ($eqPos !== false) { + $options[substr($arg, 2, $eqPos - 2)] = substr($arg, $eqPos + 1); + } else { + $options[substr($arg, 2)] = true; + } + } + } + return $options; +} +``` diff --git a/phpcs.xml b/phpcs.xml deleted file mode 100644 index 07143a4..0000000 --- a/phpcs.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - src/ - tests/ - - - vendor/* - config/* - tests/bootstrap.php - tests/object-manager.php - - diff --git a/phpinsights.php b/phpinsights.php deleted file mode 100644 index 5df088e..0000000 --- a/phpinsights.php +++ /dev/null @@ -1,60 +0,0 @@ - 'symfony', - 'exclude' => [ - 'src/Migrations', - 'src/Kernel.php', - ], - 'add' => [], - 'remove' => [ - \PHP_CodeSniffer\Standards\Generic\Sniffs\Formatting\SpaceAfterNotSniff::class, - \NunoMaduro\PhpInsights\Domain\Sniffs\ForbiddenSetterSniff::class, - \SlevomatCodingStandard\Sniffs\Commenting\UselessFunctionDocCommentSniff::class, - \SlevomatCodingStandard\Sniffs\Commenting\DocCommentSpacingSniff::class, - \SlevomatCodingStandard\Sniffs\Classes\SuperfluousInterfaceNamingSniff::class, - \SlevomatCodingStandard\Sniffs\Classes\SuperfluousExceptionNamingSniff::class, - \SlevomatCodingStandard\Sniffs\ControlStructures\DisallowYodaComparisonSniff::class, - \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenTraits::class, - \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenNormalClasses::class, - \SlevomatCodingStandard\Sniffs\Classes\SuperfluousTraitNamingSniff::class, - \SlevomatCodingStandard\Sniffs\Classes\ForbiddenPublicPropertySniff::class, - \NunoMaduro\PhpInsights\Domain\Insights\CyclomaticComplexityIsHigh::class, - \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenDefineFunctions::class, - \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenFinalClasses::class, - \NunoMaduro\PhpInsights\Domain\Insights\ForbiddenGlobals::class, - \PHP_CodeSniffer\Standards\Squiz\Sniffs\Commenting\FunctionCommentSniff::class, - \SlevomatCodingStandard\Sniffs\TypeHints\ReturnTypeHintSniff::class, - \SlevomatCodingStandard\Sniffs\Commenting\InlineDocCommentDeclarationSniff::class, - \SlevomatCodingStandard\Sniffs\Classes\ModernClassNameReferenceSniff::class, - \PHP_CodeSniffer\Standards\Generic\Sniffs\CodeAnalysis\UselessOverridingMethodSniff::class, - \SlevomatCodingStandard\Sniffs\TypeHints\DeclareStrictTypesSniff::class, - \SlevomatCodingStandard\Sniffs\TypeHints\ParameterTypeHintSniff::class, - \SlevomatCodingStandard\Sniffs\TypeHints\PropertyTypeHintSniff::class, - \SlevomatCodingStandard\Sniffs\Arrays\TrailingArrayCommaSniff::class - ], - 'config' => [ - \PHP_CodeSniffer\Standards\Generic\Sniffs\Files\LineLengthSniff::class => [ - 'lineLimit' => 120, - 'absoluteLineLimit' => 160, - ], - \SlevomatCodingStandard\Sniffs\Commenting\InlineDocCommentDeclarationSniff::class => [ - 'exclude' => [ - 'src/Exception/BaseException.php', - ], - ], - \SlevomatCodingStandard\Sniffs\ControlStructures\AssignmentInConditionSniff::class => [ - 'enabled' => false, - ], - ], - 'requirements' => [ - 'min-quality' => 80, - 'min-complexity' => 50, - 'min-architecture' => 75, - 'min-style' => 95, - 'disable-security-check' => false, - ], - 'threads' => null -]; diff --git a/phpstan.neon b/phpstan.neon deleted file mode 100644 index c3392e9..0000000 --- a/phpstan.neon +++ /dev/null @@ -1,7 +0,0 @@ -parameters: - level: max - paths: - - src - - tests - ignoreErrors: - - '#Method .* has parameter \$.* with no value type specified in iterable type array.#' diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index ba8e7af..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - tests - - - - - - src - - - diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index f0c90a3..0000000 --- a/psalm.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Cache/PhpFileCache.php b/src/Cache/PhpFileCache.php new file mode 100644 index 0000000..4944360 --- /dev/null +++ b/src/Cache/PhpFileCache.php @@ -0,0 +1,131 @@ + $variables Variable name → raw string value. + * @param string $sourceHash MD5 of the source .env file(s) for invalidation. + */ + public function dump(string $path, array $variables, string $sourceHash = ''): void + { + $metadata = [ + '__metadata' => [ + 'generated_at' => date('c'), + 'source_hash' => $sourceHash, + 'generator' => 'KaririCode\\Dotenv v4.x', + ], + ]; + + $export = var_export(array_merge($metadata, $variables), true); + + $content = <<|null Variables, or null if cache is missing/stale. + */ + public function load(string $path, string $expectedHash = ''): ?array + { + if (! is_file($path) || ! is_readable($path)) { + return null; + } + + $data = include $path; + + if (! \is_array($data)) { + return null; + } + + /** @var array $data */ + + // Validate source hash for staleness detection + if ($expectedHash !== '') { + $storedHash = isset($data['__metadata']) && \is_array($data['__metadata']) + ? ($data['__metadata']['source_hash'] ?? '') + : ''; + + if ($storedHash !== $expectedHash) { + return null; + } + } + + // Strip metadata before returning + unset($data['__metadata']); + + /** @var array $data */ + return $data; + } + + public function clear(string $path): void + { + if (is_file($path)) { + unlink($path); + + if (\function_exists('opcache_invalidate')) { + opcache_invalidate($path, true); + } + } + } + + /** + * Computes a hash of the given file paths' contents for cache invalidation. + * + * @param list $filePaths + */ + public function computeSourceHash(array $filePaths): string + { + $context = hash_init('md5'); + + foreach ($filePaths as $filePath) { + if (is_file($filePath)) { + hash_update($context, (string) filemtime($filePath)); + hash_update($context, (string) filesize($filePath)); + } + } + + return hash_final($context); + } +} diff --git a/src/Contract/Dotenv.php b/src/Contract/Dotenv.php deleted file mode 100644 index 806229d..0000000 --- a/src/Contract/Dotenv.php +++ /dev/null @@ -1,17 +0,0 @@ - Variable name → raw string value (after quote stripping and interpolation). + * + * @throws ParseException On syntax errors (invalid name, unterminated quote, circular reference). + */ + public function parse(string $content, string $filePath = '.env', bool $strictNames = false): array + { + /** @var array $variables */ + $variables = []; + /** @var list $lines */ + $lines = $this->normalizeLines($content); + $lineNumber = 0; + + while ($lineNumber < \count($lines)) { + /** @var int<0, max> $lineNumber */ + $line = $lines[$lineNumber]; + // Increment first: $lineNumber becomes 1-indexed (for error messages) and + // simultaneously equals the 0-indexed position of the next line (for multiline + // continuation in parseDoubleQuoted). This dual purpose is intentional. + ++$lineNumber; + + $trimmed = trim($line); + + // Skip empty lines and comments + if ($trimmed === '' || str_starts_with($trimmed, '#')) { + continue; + } + + // Strip optional 'export' prefix + if (str_starts_with($trimmed, 'export ')) { + $trimmed = ltrim(substr($trimmed, 7)); + } + + // Find the = separator + $equalsPos = strpos($trimmed, '='); + if ($equalsPos === false) { + // Bare variable name without value — treat as empty string + $name = $trimmed; + $rawValue = ''; + } else { + $name = rtrim(substr($trimmed, 0, $equalsPos)); + $rawValue = ltrim(substr($trimmed, $equalsPos + 1)); + } + + // Validate variable name + $namePattern = $strictNames ? self::STRICT_NAME_PATTERN : self::VARIABLE_NAME_PATTERN; + if (preg_match($namePattern, $name) !== 1) { + throw ParseException::invalidVariableName($name, $lineNumber, $filePath, $strictNames); + } + + // Parse value (handles quoting and multiline) + [$parsedValue, $consumed] = $this->parseValue($rawValue, $lines, $lineNumber, $filePath, $variables); + $lineNumber += $consumed; + + $variables[$name] = $parsedValue; + } + + return $variables; + } + + // ── Value Parsing ───────────────────────────────────────────────── + + /** + * Parses the value portion of a KEY=VALUE line. + * + * @param list $lines All source lines. + * @param array $resolvedVariables Already-parsed variables for interpolation. + * + * @return array{0: string, 1: int} Tuple of [parsed value, additional lines consumed]. + */ + private function parseValue( + string $rawValue, + array $lines, + int $currentLine, + string $filePath, + array $resolvedVariables, + ): array { + if ($rawValue === '') { + return ['', 0]; + } + + $firstChar = $rawValue[0]; + + // Double-quoted value — supports escapes, interpolation, multiline + if ($firstChar === '"') { + return $this->parseDoubleQuoted($rawValue, $lines, $currentLine, $filePath, $resolvedVariables); + } + + // Single-quoted value — literal, no escapes, no interpolation + if ($firstChar === "'") { + return $this->parseSingleQuoted($rawValue, $currentLine, $filePath); + } + + // Unquoted value — strip inline comments, apply interpolation + return [$this->parseUnquoted($rawValue, $resolvedVariables), 0]; + } + + /** + * @param list $lines + * @param array $resolvedVariables + * @return array{0: string, 1: int} + */ + private function parseDoubleQuoted( + string $rawValue, + array $lines, + int $currentLine, + string $filePath, + array $resolvedVariables, + ): array { + // Remove opening quote + $value = substr($rawValue, 1); + $result = ''; + $extraLines = 0; + + while (true) { + $length = \strlen($value); + + for ($i = 0; $i < $length; ++$i) { + $char = $value[$i]; + + // Escape sequence + if ($char === '\\' && $i + 1 < $length) { + $next = $value[$i + 1]; + $result .= match ($next) { + 'n' => "\n", + 'r' => "\r", + 't' => "\t", + '"' => '"', + '\\' => '\\', + '$' => '$', + default => '\\' . $next, + }; + ++$i; + + continue; + } + + // Closing quote found + if ($char === '"') { + $result = $this->expandVariables($result, $resolvedVariables); + + return [$result, $extraLines]; + } + + $result .= $char; + } + + // Value continues on next line + $nextLineIndex = $currentLine + $extraLines; + if ($nextLineIndex >= \count($lines)) { + throw ParseException::unterminatedQuote($currentLine, $filePath); + } + + $result .= "\n"; + /** @var int<0, max> $nextLineIndex */ + $value = $lines[$nextLineIndex]; + ++$extraLines; + } + } + + /** + * @return array{0: string, 1: int} + */ + private function parseSingleQuoted(string $rawValue, int $currentLine, string $filePath): array + { + $closingPos = strpos($rawValue, "'", 1); + + if ($closingPos === false) { + throw ParseException::unterminatedQuote($currentLine, $filePath); + } + + return [substr($rawValue, 1, $closingPos - 1), 0]; + } + + /** @param array $resolvedVariables */ + private function parseUnquoted(string $rawValue, array $resolvedVariables): string + { + // Strip inline comment: look for # preceded by whitespace, outside any quoting + $value = preg_replace('/\s+#.*$/', '', $rawValue) ?? $rawValue; + $value = trim($value); + + return $this->expandVariables($value, $resolvedVariables); + } + + // ── Variable Expansion ──────────────────────────────────────────── + + /** + * Expands ${VAR} and $VAR references against resolved variables and the environment. + * + * Resolution order: parsed variables → $_ENV → $_SERVER → empty string. + * + * @param array $resolvedVariables + */ + private function expandVariables(string $value, array $resolvedVariables): string + { + // Expand ${VAR:-default} and ${VAR:+alternate} syntax + $value = preg_replace_callback( + '/\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:[-+])(.*?))?\}/', + static function (array $matches) use ($resolvedVariables): string { + $name = $matches[1]; + $operator = $matches[2] ?? ''; + $operand = $matches[3] ?? ''; + + $resolved = $resolvedVariables[$name] + ?? (isset($_ENV[$name]) && \is_string($_ENV[$name]) ? $_ENV[$name] : null) + ?? (isset($_SERVER[$name]) && \is_string($_SERVER[$name]) ? $_SERVER[$name] : null) + ?? null; + + return (string) match ($operator) { + // ${VAR:+alternate} — use alternate if VAR is set and non-empty + ':+' => ($resolved !== null && $resolved !== '') ? $operand : '', + // ${VAR:-default} — use default if VAR is unset or empty + ':-' => ($resolved !== null && $resolved !== '') ? $resolved : $operand, + // ${VAR} — plain substitution + default => $resolved ?? '', + }; + }, + $value, + ) ?? $value; + + // Expand bare $VAR syntax + $value = preg_replace_callback( + '/\$([A-Za-z_][A-Za-z0-9_]*)/', + static function (array $matches) use ($resolvedVariables): string { + $name = $matches[1]; + + return $resolvedVariables[$name] + ?? (isset($_ENV[$name]) && \is_string($_ENV[$name]) ? $_ENV[$name] : null) + ?? (isset($_SERVER[$name]) && \is_string($_SERVER[$name]) ? $_SERVER[$name] : null) + ?? ''; + }, + $value, + ) ?? $value; + + return $value; + } + + // ── Line Normalization ──────────────────────────────────────────── + + /** + * Splits content into lines, normalizing line endings (CRLF → LF). + * + * @return list + */ + private function normalizeLines(string $content): array + { + $normalized = str_replace("\r\n", "\n", $content); + $normalized = str_replace("\r", "\n", $normalized); + + return explode("\n", $normalized); + } +} diff --git a/src/Dotenv.php b/src/Dotenv.php index 3faef46..64b751e 100644 --- a/src/Dotenv.php +++ b/src/Dotenv.php @@ -4,53 +4,561 @@ namespace KaririCode\Dotenv; -use KaririCode\Dotenv\Contract\Dotenv as DotenvContract; -use KaririCode\Dotenv\Contract\Loader; -use KaririCode\Dotenv\Contract\Parser; -use KaririCode\Dotenv\Contract\Type\TypeCaster; -use KaririCode\Dotenv\Contract\Type\TypeDetector; -use KaririCode\Dotenv\Contract\Type\TypeSystem; -use KaririCode\Dotenv\Type\DotenvTypeSystem; - -class Dotenv implements DotenvContract +use KaririCode\Dotenv\Cache\PhpFileCache; +use KaririCode\Dotenv\Contract\TypeCaster; +use KaririCode\Dotenv\Contract\TypeDetector; +use KaririCode\Dotenv\Contract\VariableProcessor; +use KaririCode\Dotenv\Core\DotenvParser; +use KaririCode\Dotenv\Enum\LoadMode; +use KaririCode\Dotenv\Enum\ValueType; +use KaririCode\Dotenv\Exception\FileNotFoundException; +use KaririCode\Dotenv\Exception\ImmutableException; +use KaririCode\Dotenv\Exception\ValidationException; +use KaririCode\Dotenv\Schema\SchemaParser; +use KaririCode\Dotenv\Security\Encryptor; +use KaririCode\Dotenv\Type\TypeSystem; +use KaririCode\Dotenv\Validation\EnvironmentValidator; +use KaririCode\Dotenv\ValueObject\DotenvConfiguration; +use KaririCode\Dotenv\ValueObject\EnvironmentVariable; + +/** + * Production-grade .env file loader for the KaririCode Framework. + * + * The first and only PHP dotenv that combines: auto type casting, + * native AES-256-GCM encryption, OPcache-friendly caching, + * fluent validation DSL, environment-aware cascade loading, + * and variable processors — all with zero external dependencies. + * + * ## Quick Start + * + * ```php + * $dotenv = new Dotenv('/path/to/project'); + * $dotenv->load(); + * + * $debug = env('APP_DEBUG'); // bool: true + * $port = env('DB_PORT'); // int: 5432 + * ``` + * + * ## Encrypted .env + * + * ```php + * $config = new DotenvConfiguration( + * encryptionKey: $_SERVER['DOTENV_PRIVATE_KEY'] ?? null, + * ); + * $dotenv = new Dotenv('/path/to/project', $config); + * $dotenv->load(); // Transparently decrypts "encrypted:..." values + * ``` + * + * ## Validation DSL + * + * ```php + * $dotenv->validate() + * ->required('DB_HOST', 'DB_PORT') + * ->isInteger('DB_PORT')->between(1, 65535) + * ->isBoolean('APP_DEBUG') + * ->allowedValues('APP_ENV', ['local', 'staging', 'production']) + * ->assert(); + * ``` + * + * ## Environment-Aware Loading + * + * ```php + * $dotenv = new Dotenv('/path/to/project'); + * $dotenv->bootEnv(); // .env → .env.local → .env.{APP_ENV} → .env.{APP_ENV}.local + * ``` + * + * ARFA 1.3 Compliance: + * - P1 Immutable State: Configuration and EnvironmentVariable are readonly. + * - P3 Adaptive Context: TypeSystem + processors are extensible. + * - P4 Protocol Agnostic: Parser accepts any string content. + * - P5 Continuous Observability: debug() provides full variable introspection. + * + * @package KaririCode\Dotenv + * @since 4.0.0 ARFA 1.3 + */ +final class Dotenv { + private readonly DotenvParser $parser; + private readonly TypeSystem $typeSystem; + private readonly DotenvConfiguration $configuration; + private readonly string $directory; + + /** @var list Resolved file paths to load. */ + private array $filePaths = []; + + /** @var array Loaded variables keyed by name. */ + private array $variables = []; + + /** @var array> Pattern → processors. */ + private array $processors = []; + + private ?Encryptor $encryptor = null; + private bool $loaded = false; + public function __construct( - private Parser $parser, - private Loader $loader, - private TypeSystem $typeSystem = new DotenvTypeSystem() + string $directory, + ?DotenvConfiguration $configuration = null, + string ...$fileNames, ) { + $this->directory = rtrim($directory, DIRECTORY_SEPARATOR); + $this->parser = new DotenvParser(); + $this->typeSystem = new TypeSystem(); + $this->configuration = $configuration ?? new DotenvConfiguration(); + + // Initialize encryptor if key provided via config or environment + $encryptionKey = $this->configuration->encryptionKey + ?? (\is_string($_SERVER['DOTENV_PRIVATE_KEY'] ?? null) ? $_SERVER['DOTENV_PRIVATE_KEY'] : null) + ?? (\is_string($_ENV['DOTENV_PRIVATE_KEY'] ?? null) ? $_ENV['DOTENV_PRIVATE_KEY'] : null) + ?? null; + + if ($encryptionKey !== null && $encryptionKey !== '') { + $this->encryptor = new Encryptor($encryptionKey); + } + + if ($fileNames === []) { + $fileNames = ['.env']; + } + + foreach ($fileNames as $fileName) { + $this->filePaths[] = $this->directory . DIRECTORY_SEPARATOR . $fileName; + } } + // ── Public API ──────────────────────────────────────────────────── + + /** + * Loads all configured .env files. If a cache file exists and is fresh, + * loads from cache instead (zero parsing cost via OPcache). + * + * @throws FileNotFoundException When a required file does not exist. + * @throws ImmutableException When a variable conflicts in Immutable mode. + */ public function load(): void { - $content = $this->loader->load(); - $parsed = $this->parser->parse($content); + if ($this->loadFromCache()) { + $this->loaded = true; + + return; + } + + foreach ($this->filePaths as $filePath) { + $this->loadFile($filePath, required: true); + } + + $this->loaded = true; + } + + /** + * Loads .env files that exist, silently skipping missing ones. + */ + public function safeLoad(): void + { + if ($this->loadFromCache()) { + $this->loaded = true; + + return; + } + + foreach ($this->filePaths as $filePath) { + $this->loadFile($filePath, required: false); + } + + $this->loaded = true; + } + + /** + * Environment-aware cascade loading (inspired by Symfony's bootEnv). + * + * Loads files in order: + * 1. `.env` — base defaults (committed) + * 2. `.env.local` — local overrides (gitignored) + * 3. `.env.{env}` — environment-specific defaults (committed) + * 4. `.env.{env}.local` — environment-specific local overrides (gitignored) + * + * The environment name is resolved from $environmentName parameter, + * configuration's environmentName, the APP_ENV variable, or defaults to "dev". + */ + public function bootEnv(?string $environmentName = null): void + { + if ($this->loadFromCache()) { + $this->loaded = true; + + return; + } + + $basePath = $this->directory . DIRECTORY_SEPARATOR . '.env'; + + // 1. Base .env + $this->loadFile($basePath, required: false); + + // 2. .env.local (always loaded for local overrides) + $this->loadFile($basePath . '.local', required: false); + + // Determine environment name + $envName = $environmentName + ?? $this->configuration->environmentName + ?? $this->resolveRawValue('APP_ENV') + ?? 'dev'; + + // 3. .env.{env} (committed env-specific defaults) + $this->loadFile("{$basePath}.{$envName}", required: false); + + // 4. .env.{env}.local (skip for "test" to ensure reproducibility) + if ($envName !== 'test') { + $this->loadFile("{$basePath}.{$envName}.local", required: false); + } + + $this->loaded = true; + } + + // ── Validation ──────────────────────────────────────────────────── + + /** + * Simple required-presence check (backward compatible with v4.0). + * + * @throws ValidationException When any required variable is missing. + */ + public function required(string ...$names): void + { + $missing = array_filter( + $names, + fn (string $name): bool => ! isset($this->variables[$name]) + && ! isset($_ENV[$name]) + && ! isset($_SERVER[$name]), + ); + + if ($missing !== []) { + throw ValidationException::missingRequired(array_values($missing)); + } + } + + /** + * Returns a fluent validation builder. Call ->assert() to execute. + * + * ```php + * $dotenv->validate() + * ->required('DB_HOST', 'DB_PORT') + * ->isInteger('DB_PORT')->between(1, 65535) + * ->assert(); + * ``` + */ + public function validate(): EnvironmentValidator + { + return new EnvironmentValidator( + fn (string $name): ?string => $this->resolveRawValue($name), + ); + } + + /** + * Loads and applies a .env.schema file for declarative validation. + * + * @throws ValidationException On any schema violation. + * @throws FileNotFoundException When the schema file is missing. + */ + public function loadWithSchema(string $schemaPath): void + { + if (! $this->loaded) { + $this->load(); + } + + if (! is_file($schemaPath) || ! is_readable($schemaPath)) { + throw FileNotFoundException::forPath($schemaPath); + } + + $content = file_get_contents($schemaPath); + + if ($content === false) { + throw FileNotFoundException::forPath($schemaPath); + } + + $schemaParser = new SchemaParser(); + $schema = $schemaParser->parse($content); + $validator = $this->validate(); + $schemaParser->applyToValidator($schema, $validator); + $validator->assert(); + } + + // ── Accessors ───────────────────────────────────────────────────── + + public function get(string $name, mixed $default = null): mixed + { + return isset($this->variables[$name]) + ? $this->variables[$name]->value + : $default; + } + + /** @return array */ + public function variables(): array + { + return $this->variables; + } + + public function isLoaded(): bool + { + return $this->loaded; + } + + // ── Debug & Introspection ───────────────────────────────────────── + + /** + * Returns a debug report with source tracking, types, and override info. + * + * @return array + */ + public function debug(): array + { + $report = []; + + foreach ($this->variables as $name => $var) { + $report[$name] = [ + 'source' => $var->source, + 'rawValue' => $var->rawValue, + 'type' => $var->type->name, + 'value' => $var->value, + 'overridden' => $var->overridden, + ]; + } + + return $report; + } + + // ── Cache ───────────────────────────────────────────────────────── + + /** + * Dumps all loaded variables to a PHP array cache file. + * OPcache compiles this once — subsequent requests load from shared memory. + */ + public function dumpCache(string $path): void + { + $cache = new PhpFileCache(); + $rawVariables = []; + + foreach ($this->variables as $name => $var) { + $rawVariables[$name] = $var->rawValue; + } + + $hash = $cache->computeSourceHash($this->filePaths); + $cache->dump($path, $rawVariables, $hash); + } + + public function clearCache(string $path): void + { + new PhpFileCache()->clear($path); + } + + // ── Extension Points ────────────────────────────────────────────── + + public function addTypeDetector(TypeDetector $detector): void + { + $this->typeSystem->addDetector($detector); + } + + public function addTypeCaster(ValueType $type, TypeCaster $caster): void + { + $this->typeSystem->addCaster($type, $caster); + } + + /** + * Registers a post-load processor for variables matching a name or glob pattern. + * + * @param string $pattern Exact name or glob (e.g., "*_URL", "ALLOWED_IPS"). + * @param VariableProcessor $processor Transformer to apply after type casting. + */ + public function addProcessor(string $pattern, VariableProcessor $processor): void + { + $this->processors[$pattern][] = $processor; + } + + // ── Internal ────────────────────────────────────────────────────── + + private function loadFromCache(): bool + { + $cachePath = $this->configuration->cachePath; + + if ($cachePath === null) { + return false; + } + + $cache = new PhpFileCache(); + $hash = $cache->computeSourceHash($this->filePaths); + $cached = $cache->load($cachePath, $hash); + + if ($cached === null) { + return false; + } + + foreach ($cached as $name => $rawValue) { + $this->setVariable($name, $rawValue, 'cache'); + } + + return true; + } + + private function loadFile(string $filePath, bool $required): void + { + if (! is_file($filePath) || ! is_readable($filePath)) { + if ($required) { + throw FileNotFoundException::forPath($filePath); + } + + return; + } + + $content = file_get_contents($filePath); + + if ($content === false) { + if ($required) { + throw FileNotFoundException::forPath($filePath); + } + + return; + } + + $rawVariables = $this->parser->parse( + $content, + $filePath, + $this->configuration->strictNames, + ); + + $source = basename($filePath); + + foreach ($rawVariables as $name => $rawValue) { + $this->setVariable($name, $rawValue, $source); + } + } + + private function setVariable(string $name, string $rawValue, string $source): void + { + // Allow/Deny list filtering + if (! $this->isAllowed($name)) { + return; + } + + $alreadyExists = isset($_ENV[$name]) || isset($_SERVER[$name]) || getenv($name) !== false; + + // Immutable: throw if variable existed BEFORE this instance loaded it + if ($this->configuration->loadMode === LoadMode::Immutable + && $alreadyExists + && ! isset($this->variables[$name]) + ) { + throw ImmutableException::alreadyDefined($name); + } + + // SkipExisting: silently skip pre-existing environment variables + if ($this->configuration->loadMode === LoadMode::SkipExisting && $alreadyExists) { + return; + } + + // Decrypt encrypted values transparently + $decryptedValue = $rawValue; + if ($this->encryptor !== null && Encryptor::isEncrypted($rawValue)) { + $decryptedValue = $this->encryptor->decrypt($rawValue); + } - if (!empty($parsed)) { - foreach ($parsed as $key => $value) { - $processedValue = $this->typeSystem->processValue($value); - $this->setEnvironmentVariable($key, $processedValue); + // Type detection and casting + $type = ValueType::String; + $typedValue = $decryptedValue; + + if ($this->configuration->typeCasting) { + $type = $this->typeSystem->detect($decryptedValue); + $typedValue = $this->typeSystem->cast($decryptedValue, $type); + } + + // Apply registered processors + $typedValue = $this->applyProcessors($name, $decryptedValue, $typedValue); + + $overridden = isset($this->variables[$name]); + $this->variables[$name] = new EnvironmentVariable( + $name, + $decryptedValue, + $type, + $typedValue, + $source, + $overridden, + ); + + // Populate environment with decrypted raw string + if ($this->configuration->populateEnv) { + $_ENV[$name] = $decryptedValue; + } + + if ($this->configuration->populateServer) { + $_SERVER[$name] = $decryptedValue; + } + + if ($this->configuration->usePutenv) { + putenv("{$name}={$decryptedValue}"); + } + } + + private function isAllowed(string $name): bool + { + // Deny list takes precedence + foreach ($this->configuration->denyList as $pattern) { + if ($this->matchGlob($pattern, $name)) { + return false; + } + } + + // Empty allow list means everything allowed + if ($this->configuration->allowList === []) { + return true; + } + + foreach ($this->configuration->allowList as $pattern) { + if ($this->matchGlob($pattern, $name)) { + return true; } } + + return false; } - public function addTypeDetector(TypeDetector $detector): self + private function matchGlob(string $pattern, string $name): bool { - $this->typeSystem->registerDetector($detector); + if ($pattern === $name) { + return true; + } - return $this; + $regex = '/\A' . str_replace( + ['\*', '\?'], + ['.*', '.'], + preg_quote($pattern, '/'), + ) . '\z/'; + + return preg_match($regex, $name) === 1; } - public function addTypeCaster(string $type, TypeCaster $caster): self + private function applyProcessors(string $name, string $rawValue, mixed $typedValue): mixed { - $this->typeSystem->registerCaster($type, $caster); + foreach ($this->processors as $pattern => $processorList) { + if ($this->matchGlob($pattern, $name)) { + foreach ($processorList as $processor) { + $typedValue = $processor->process($rawValue, $typedValue); + } + } + } - return $this; + return $typedValue; } - private function setEnvironmentVariable(string $key, mixed $value): void + private function resolveRawValue(string $name): ?string { - $_ENV[$key] = $value; - $_SERVER[$key] = $value; + if (isset($this->variables[$name])) { + return $this->variables[$name]->rawValue; + } + + $envVal = $_ENV[$name] ?? null; + $serverVal = $_SERVER[$name] ?? null; + + if (\is_string($envVal)) { + return $envVal; + } + + if (\is_string($serverVal)) { + return $serverVal; + } + + return null; } } diff --git a/src/DotenvFactory.php b/src/DotenvFactory.php deleted file mode 100644 index eab9a53..0000000 --- a/src/DotenvFactory.php +++ /dev/null @@ -1,20 +0,0 @@ - */ + private array $errors = []; + + /** @param list $missing */ + public static function missingRequired(array $missing): self + { + return new self( + 'Missing required environment variables: ' . implode(', ', $missing), + ); + } + + /** @param list $errors */ + public static function batchErrors(array $errors): self + { + $exception = new self( + "Environment validation failed:\n- " . implode("\n- ", $errors), + ); + $exception->errors = $errors; + + return $exception; + } + + public static function schemaViolation(string $message): self + { + return new self("Schema validation failed: {$message}"); + } + + /** @return list */ + public function errors(): array + { + return $this->errors; + } +} diff --git a/src/Loader/ArrayLoader.php b/src/Loader/ArrayLoader.php deleted file mode 100644 index e9a1cb0..0000000 --- a/src/Loader/ArrayLoader.php +++ /dev/null @@ -1,27 +0,0 @@ -variables = $variables; - } - - public function load(): string - { - $output = ''; - foreach ($this->variables as $key => $value) { - $output .= "$key=$value\n"; - } - - return $output; - } -} diff --git a/src/Loader/FileLoader.php b/src/Loader/FileLoader.php deleted file mode 100644 index 05031ac..0000000 --- a/src/Loader/FileLoader.php +++ /dev/null @@ -1,38 +0,0 @@ -filePath = $filePath; - } - - public function load(): string - { - if (!file_exists($this->filePath)) { - throw new InvalidFileException(sprintf('The environment file %s does not exist.', $this->filePath)); - } - - $contents = $this->getFileContents($this->filePath); - - if (false === $contents) { - throw new InvalidFileException(sprintf('Unable to read the environment file at %s.', $this->filePath)); - } - - return $contents; - } - - protected function getFileContents(string $filePath): string|false - { - return file_get_contents($filePath); - } -} diff --git a/src/Parser/AbstractParser.php b/src/Parser/AbstractParser.php deleted file mode 100644 index 163511c..0000000 --- a/src/Parser/AbstractParser.php +++ /dev/null @@ -1,42 +0,0 @@ -splitLines($content); - $parsedValues = $this->parseLines($lines); - - return $this->interpolateValues($parsedValues); - } - - abstract protected function parseLines(array $lines): array; - - protected function splitLines(string $content): array - { - return preg_split('/\r\n|\r|\n/', $content); - } - - protected function interpolateValues(array $parsedValues): array - { - return array_map( - fn ($value) => $this->interpolateValue($value, $parsedValues), - $parsedValues - ); - } - - protected function interpolateValue(string $value, array $parsedValues): string - { - return preg_replace_callback( - '/\${([A-Z0-9_]+)}/', - fn ($matches) => $parsedValues[$matches[1]] ?? $matches[0], - $value - ); - } -} diff --git a/src/Parser/DefaultParser.php b/src/Parser/DefaultParser.php deleted file mode 100644 index 437990a..0000000 --- a/src/Parser/DefaultParser.php +++ /dev/null @@ -1,27 +0,0 @@ -isValidSetter($line)) { - [$key, $value] = $this->parseEnvironmentVariable($line); - if ($this->isValidKey($key)) { - $parsedValues[$key] = $this->sanitizeValue($value); - } - } - } - - return $parsedValues; - } -} diff --git a/src/Parser/StrictParser.php b/src/Parser/StrictParser.php deleted file mode 100644 index 096322d..0000000 --- a/src/Parser/StrictParser.php +++ /dev/null @@ -1,54 +0,0 @@ -isValidSetter($line)) { - [$key, $value] = $this->parseEnvironmentVariable($line); - $this->validateVariableName($key); - $parsedValues[$key] = $this->sanitizeValue($value); - } - } - - return $parsedValues; - } - - private function validateVariableName(?string $name): void - { - if (!$this->isValidKey($name)) { - throw new InvalidValueException('Empty variable name'); - } - - if ($this->containsInvalidCharacters($name)) { - throw new InvalidValueException('Invalid character in variable name'); - } - - if (!$this->startsWithValidCharacter($name)) { - throw new InvalidValueException('Variable name must start with a letter or underscore'); - } - } - - private function containsInvalidCharacters(string $name): bool - { - return false !== strpbrk($name, self::INVALID_NAME_CHARS); - } - - private function startsWithValidCharacter(string $name): bool - { - return 1 === preg_match('/^[a-zA-Z_]/', $name); - } -} diff --git a/src/Parser/Trait/CommonParserFunctionality.php b/src/Parser/Trait/CommonParserFunctionality.php deleted file mode 100644 index 14ca00a..0000000 --- a/src/Parser/Trait/CommonParserFunctionality.php +++ /dev/null @@ -1,60 +0,0 @@ -isComment($trimmedLine) && $this->containsSetterChar($trimmedLine); - } - - protected function isComment(string $line): bool - { - return 1 === preg_match('/^\s*#/', $line); - } - - protected function containsSetterChar(string $line): bool - { - return str_contains($line, '='); - } - - protected function parseEnvironmentVariable(string $line): array - { - $parts = explode('=', $line, 2); - if (2 !== count($parts)) { - return [null, null]; - } - - return [trim($parts[0]) ?: null, trim($parts[1])]; - } - - protected function sanitizeValue(string $value): string - { - $value = trim($value); - - return $this->removeQuotes($value); - } - - protected function removeQuotes(string $value): string - { - if (strlen($value) > 1) { - $first = $value[0]; - $last = $value[strlen($value) - 1]; - if (('"' === $first && '"' === $last) || ("'" === $first && "'" === $last)) { - return substr($value, 1, -1); - } - } - - return $value; - } - - protected function isValidKey(?string $key): bool - { - return null !== $key && '' !== $key; - } -} diff --git a/src/Processor/Base64DecodeProcessor.php b/src/Processor/Base64DecodeProcessor.php new file mode 100644 index 0000000..1391b22 --- /dev/null +++ b/src/Processor/Base64DecodeProcessor.php @@ -0,0 +1,22 @@ + */ + #[\Override] + public function process(string $rawValue, mixed $typedValue): array + { + if (trim($rawValue) === '') { + return []; + } + + $separator = $this->separator !== '' ? $this->separator : ','; + + return array_map(trim(...), explode($separator, $rawValue)); + } +} diff --git a/src/Processor/TrimProcessor.php b/src/Processor/TrimProcessor.php new file mode 100644 index 0000000..f2bee77 --- /dev/null +++ b/src/Processor/TrimProcessor.php @@ -0,0 +1,21 @@ +characters); + } +} diff --git a/src/Processor/UrlNormalizerProcessor.php b/src/Processor/UrlNormalizerProcessor.php new file mode 100644 index 0000000..c8cae7a --- /dev/null +++ b/src/Processor/UrlNormalizerProcessor.php @@ -0,0 +1,16 @@ +> Variable name → directive → value. + */ + public function parse(string $content): array + { + $schema = []; + $currentSection = null; + + foreach (explode("\n", $content) as $line) { + $line = trim($line); + + // Skip empty lines and comments + if ($line === '' || $line[0] === '#' || $line[0] === ';') { + continue; + } + + // Section header: [VAR_NAME] + if (preg_match('/^\[([A-Za-z_][A-Za-z0-9_]*)\]$/', $line, $matches)) { + $currentSection = $matches[1]; + $schema[$currentSection] ??= []; + + continue; + } + + // Directive: key = value + if ($currentSection !== null && str_contains($line, '=')) { + $eqPos = strpos($line, '='); + + if ($eqPos === false) { + continue; + } + + $key = trim(substr($line, 0, $eqPos)); + $value = trim(substr($line, $eqPos + 1)); + $schema[$currentSection][$key] = $value; + } + } + + return $schema; + } + + /** + * Applies parsed schema rules to an EnvironmentValidator. + * + * @param array> $schema + */ + public function applyToValidator( + array $schema, + EnvironmentValidator $validator, + ): void { + foreach ($schema as $variableName => $directives) { + $isRequired = $this->boolDirective($directives, 'required'); + + if ($isRequired) { + $validator->required($variableName); + } else { + $validator->ifPresent($variableName); + } + + // notEmpty + if ($this->boolDirective($directives, 'notEmpty')) { + $validator->notEmpty($variableName); + } + + // Type-based rules + $type = $directives['type'] ?? null; + if ($type !== null) { + match ($type) { + 'integer' => $validator->isInteger($variableName), + 'boolean' => $validator->isBoolean($variableName), + 'numeric' => $validator->isNumeric($variableName), + 'email' => $validator->email($variableName), + 'url' => $validator->url($variableName), + 'string' => null, // No additional rule needed + default => throw ValidationException::schemaViolation( + "Unknown type '{$type}' for [{$variableName}].", + ), + }; + } + + // Numeric range (min/max) + $min = isset($directives['min']) ? (float) $directives['min'] : null; + $max = isset($directives['max']) ? (float) $directives['max'] : null; + + if ($min !== null && $max !== null) { + $validator->between($min, $max); + } + + // Allowed values + if (isset($directives['allowed'])) { + $allowed = array_map(trim(...), explode(',', $directives['allowed'])); + $validator->allowedValues($variableName, $allowed); + } + + // Regex + if (isset($directives['regex'])) { + $validator->matchesRegex($variableName, $directives['regex']); + } + } + } + + /** @param array $directives */ + private function boolDirective(array $directives, string $key): bool + { + $value = $directives[$key] ?? 'false'; + + return \in_array(strtolower($value), ['true', '1', 'yes', 'on'], true); + } +} diff --git a/src/Security/Encryptor.php b/src/Security/Encryptor.php new file mode 100644 index 0000000..0f9426c --- /dev/null +++ b/src/Security/Encryptor.php @@ -0,0 +1,113 @@ +` + * + * Uses PHP's ext-openssl (bundled in standard PHP distributions). + * No external tools or Node.js CLI required — unlike dotenvx. + * + * ```php + * $encryptor = new Encryptor($privateKey); + * $encrypted = $encryptor->encrypt('my-secret'); + * // → "encrypted:base64..." + * + * $decrypted = $encryptor->decrypt($encrypted); + * // → "my-secret" + * ``` + * + * @package KaririCode\Dotenv + * @since 4.3.0 + */ +final class Encryptor +{ + private const string CIPHER = 'aes-256-gcm'; + private const int NONCE_LENGTH = 12; + private const int TAG_LENGTH = 16; + private const string PREFIX = 'encrypted:'; + + private readonly string $key; + + /** + * @param string $key 64-char hex string (256-bit key) or 32-byte raw binary. + */ + public function __construct(string $key) + { + $this->key = \strlen($key) === 64 && ctype_xdigit($key) + ? (string) hex2bin($key) + : $key; + + if (\strlen($this->key) !== 32) { + throw new \InvalidArgumentException( + 'Encryption key must be 32 bytes (256-bit) or 64-char hex string.', + ); + } + } + + public function encrypt(string $plaintext): string + { + $nonce = random_bytes(self::NONCE_LENGTH); + $tag = ''; + + $ciphertext = openssl_encrypt( + $plaintext, + self::CIPHER, + $this->key, + OPENSSL_RAW_DATA, + $nonce, + $tag, + '', + self::TAG_LENGTH, + ); + + if ($ciphertext === false) { + throw new \RuntimeException('Encryption failed: ' . (string) openssl_error_string()); + } + + return self::PREFIX . base64_encode($nonce . $ciphertext . $tag); + } + + public function decrypt(string $payload): string + { + if (! self::isEncrypted($payload)) { + return $payload; + } + + $raw = base64_decode(substr($payload, \strlen(self::PREFIX)), true); + + if ($raw === false || \strlen($raw) < self::NONCE_LENGTH + self::TAG_LENGTH) { + throw new \RuntimeException('Invalid encrypted payload: malformed base64 or too short.'); + } + + $nonce = substr($raw, 0, self::NONCE_LENGTH); + $tag = substr($raw, -self::TAG_LENGTH); + $ciphertext = substr($raw, self::NONCE_LENGTH, -self::TAG_LENGTH); + + $plaintext = openssl_decrypt( + $ciphertext, + self::CIPHER, + $this->key, + OPENSSL_RAW_DATA, + $nonce, + $tag, + ); + + if ($plaintext === false) { + throw new \RuntimeException( + 'Decryption failed — wrong key or corrupted payload.', + ); + } + + return $plaintext; + } + + public static function isEncrypted(string $value): bool + { + return str_starts_with($value, self::PREFIX); + } +} diff --git a/src/Security/KeyPair.php b/src/Security/KeyPair.php new file mode 100644 index 0000000..acd2918 --- /dev/null +++ b/src/Security/KeyPair.php @@ -0,0 +1,56 @@ +privateKey = $privateKey; + $this->publicId = $publicId; + } + + /** + * Generates a new random key pair. + * + * The private key is the actual 256-bit AES key (hex-encoded). + * The public ID is an 8-char identifier for referencing this key + * in multi-environment setups (derived from the key hash). + */ + public static function generate(): self + { + $rawKey = random_bytes(32); + $privateKey = bin2hex($rawKey); + $publicId = substr(hash('sha256', $rawKey), 0, 8); + + return new self($privateKey, $publicId); + } + + /** + * Reconstitutes a KeyPair from an existing private key. + */ + public static function fromPrivateKey(string $privateKey): self + { + if (\strlen($privateKey) !== 64 || ! ctype_xdigit($privateKey)) { + throw new \InvalidArgumentException( + 'Private key must be a 64-character hex string (256-bit).', + ); + } + + $publicId = substr(hash('sha256', (string) hex2bin($privateKey)), 0, 8); + + return new self($privateKey, $publicId); + } +} diff --git a/src/Type/Caster/ArrayCaster.php b/src/Type/Caster/ArrayCaster.php index 601f9c0..48650f2 100644 --- a/src/Type/Caster/ArrayCaster.php +++ b/src/Type/Caster/ArrayCaster.php @@ -4,22 +4,22 @@ namespace KaririCode\Dotenv\Type\Caster; -use KaririCode\Dotenv\Contract\Type\TypeCaster; +use KaririCode\Dotenv\Contract\TypeCaster; -class ArrayCaster implements TypeCaster +/** + * Decodes a JSON array string into a PHP list array. + * + * @package KaririCode\Dotenv + * @since 4.0.0 ARFA 1.3 + */ +final readonly class ArrayCaster implements TypeCaster { - public function cast(mixed $value): array + /** @return list */ + #[\Override] + public function cast(string $value): array { - if (is_string($value)) { - $trimmed = trim($value, "[] \t\n\r\0\x0B"); - if ('' === $trimmed) { - return []; - } - $items = explode(',', $trimmed); + $decoded = json_decode(trim($value), true, 512, JSON_THROW_ON_ERROR); - return array_map(fn ($item) => trim($item, " \t\n\r\0\x0B\"'"), $items); - } - - return (array) $value; + return \is_array($decoded) ? array_values($decoded) : []; } } diff --git a/src/Type/Caster/BooleanCaster.php b/src/Type/Caster/BooleanCaster.php index c462ba5..34e7c23 100644 --- a/src/Type/Caster/BooleanCaster.php +++ b/src/Type/Caster/BooleanCaster.php @@ -4,23 +4,19 @@ namespace KaririCode\Dotenv\Type\Caster; -use KaririCode\Dotenv\Contract\Type\TypeCaster; +use KaririCode\Dotenv\Contract\TypeCaster; -class BooleanCaster implements TypeCaster +/** + * @package KaririCode\Dotenv + * @since 4.0.0 ARFA 1.3 + */ +final readonly class BooleanCaster implements TypeCaster { - public function canCast(mixed $value): bool - { - $value = strtolower($value); - - return in_array($value, [ - 'true', 'false', '1', '0', 'yes', 'no', 'on', 'off', - ], true); - } + private const array TRUE_VALUES = ['true', 'yes', 'on', '(true)']; - public function cast(mixed $value): bool + #[\Override] + public function cast(string $value): bool { - $value = strtolower($value); - - return in_array($value, ['true', '1', 'yes', 'on'], true); + return \in_array(strtolower($value), self::TRUE_VALUES, true); } } diff --git a/src/Type/Caster/DotenvTypeCasterRegistry.php b/src/Type/Caster/DotenvTypeCasterRegistry.php deleted file mode 100644 index 4db2ca4..0000000 --- a/src/Type/Caster/DotenvTypeCasterRegistry.php +++ /dev/null @@ -1,46 +0,0 @@ -casters = new ArrayList(); - $this->registerDefaultCasters(); - } - - public function register(string $type, TypeCaster $caster): void - { - $this->casters->set($type, $caster); - } - - public function cast(string $type, mixed $value): mixed - { - $caster = $this->casters->get($type); - if ($caster instanceof TypeCaster) { - return $caster->cast($value); - } - - return $value; // Fallback: return original value if no caster found - } - - private function registerDefaultCasters(): void - { - $this->register('array', new ArrayCaster()); - $this->register('json', new JsonCaster()); - $this->register('null', new NullCaster()); - $this->register('boolean', new BooleanCaster()); - $this->register('integer', new IntegerCaster()); - $this->register('float', new FloatCaster()); - $this->register('string', new StringCaster()); - } -} diff --git a/src/Type/Caster/FloatCaster.php b/src/Type/Caster/FloatCaster.php index de2f4f1..9d70d0d 100644 --- a/src/Type/Caster/FloatCaster.php +++ b/src/Type/Caster/FloatCaster.php @@ -4,16 +4,16 @@ namespace KaririCode\Dotenv\Type\Caster; -use KaririCode\Dotenv\Contract\Type\TypeCaster; +use KaririCode\Dotenv\Contract\TypeCaster; -class FloatCaster implements TypeCaster +/** + * @package KaririCode\Dotenv + * @since 4.0.0 ARFA 1.3 + */ +final readonly class FloatCaster implements TypeCaster { - public function canCast(mixed $value): bool - { - return is_numeric($value) && false !== strpos((string) $value, '.'); - } - - public function cast(mixed $value): float + #[\Override] + public function cast(string $value): float { return (float) $value; } diff --git a/src/Type/Caster/IntegerCaster.php b/src/Type/Caster/IntegerCaster.php index 30eb0b4..36b9111 100644 --- a/src/Type/Caster/IntegerCaster.php +++ b/src/Type/Caster/IntegerCaster.php @@ -4,16 +4,16 @@ namespace KaririCode\Dotenv\Type\Caster; -use KaririCode\Dotenv\Contract\Type\TypeCaster; +use KaririCode\Dotenv\Contract\TypeCaster; -class IntegerCaster implements TypeCaster +/** + * @package KaririCode\Dotenv + * @since 4.0.0 ARFA 1.3 + */ +final readonly class IntegerCaster implements TypeCaster { - public function canCast(mixed $value): bool - { - return is_numeric($value) && (string) (int) $value === (string) $value; - } - - public function cast(mixed $value): int + #[\Override] + public function cast(string $value): int { return (int) $value; } diff --git a/src/Type/Caster/JsonCaster.php b/src/Type/Caster/JsonCaster.php index e87acb2..f0b4590 100644 --- a/src/Type/Caster/JsonCaster.php +++ b/src/Type/Caster/JsonCaster.php @@ -4,38 +4,26 @@ namespace KaririCode\Dotenv\Type\Caster; -use KaririCode\Dotenv\Contract\Type\TypeCaster; - -class JsonCaster implements TypeCaster +use KaririCode\Dotenv\Contract\TypeCaster; + +/** + * Decodes a JSON object string into a PHP associative array. + * + * Throws JsonException on malformed input rather than returning false/null, + * ensuring fail-fast behavior in production. + * + * @package KaririCode\Dotenv + * @since 4.0.0 ARFA 1.3 + */ +final readonly class JsonCaster implements TypeCaster { - /** - * @throws \JsonException - */ - public function cast(mixed $value): mixed - { - if (!is_string($value)) { - return $value; - } - - $trimmedValue = $this->removeSurroundingQuotes($value); - - return $this->decodeJson($trimmedValue); - } - - private function removeSurroundingQuotes(string $value): string + /** @return array */ + #[\Override] + public function cast(string $value): array { - return trim($value, '"\''); - } + $decoded = json_decode(trim($value), true, 512, JSON_THROW_ON_ERROR); - /** - * @throws \JsonException - */ - private function decodeJson(string $json): mixed - { - try { - return json_decode($json, true, 512, JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - return $json; - } + /** @var array $decoded */ + return $decoded; } } diff --git a/src/Type/Caster/NullCaster.php b/src/Type/Caster/NullCaster.php index 2089e2d..21838fc 100644 --- a/src/Type/Caster/NullCaster.php +++ b/src/Type/Caster/NullCaster.php @@ -4,16 +4,16 @@ namespace KaririCode\Dotenv\Type\Caster; -use KaririCode\Dotenv\Contract\Type\TypeCaster; +use KaririCode\Dotenv\Contract\TypeCaster; -class NullCaster implements TypeCaster +/** + * @package KaririCode\Dotenv + * @since 4.0.0 ARFA 1.3 + */ +final readonly class NullCaster implements TypeCaster { - public function canCast(mixed $value): bool - { - return 'null' === $value || '' === $value; - } - - public function cast(mixed $value): ?string + #[\Override] + public function cast(string $value): null { return null; } diff --git a/src/Type/Caster/StringCaster.php b/src/Type/Caster/StringCaster.php deleted file mode 100644 index 2080664..0000000 --- a/src/Type/Caster/StringCaster.php +++ /dev/null @@ -1,24 +0,0 @@ -isStringInput($value)) { - return null; - } + return 150; + } - $cleanValue = $this->removeWhitespace($value); + #[\Override] + public function detect(string $value): ?ValueType + { + $trimmed = trim($value); - if ($this->isArrayFormat($cleanValue)) { - return 'array'; + if (! str_starts_with($trimmed, '[') || ! str_ends_with($trimmed, ']')) { + return null; } - return null; - } + $decoded = json_decode($trimmed, true); - private function isArrayFormat(string $value): bool - { - return $this->hasDelimiters($value, '[', ']'); + return json_last_error() === JSON_ERROR_NONE && \is_array($decoded) ? ValueType::Array : null; } } diff --git a/src/Type/Detector/BooleanDetector.php b/src/Type/Detector/BooleanDetector.php index 1b548f4..7e5bf68 100644 --- a/src/Type/Detector/BooleanDetector.php +++ b/src/Type/Detector/BooleanDetector.php @@ -4,22 +4,39 @@ namespace KaririCode\Dotenv\Type\Detector; -class BooleanDetector extends AbstractTypeDetector +use KaririCode\Dotenv\Contract\TypeDetector; +use KaririCode\Dotenv\Enum\ValueType; + +/** + * Detects boolean literals: true/false, yes/no, on/off (case-insensitive). + * + * Uses normalized lowercase comparison to match the same values the + * BooleanCaster handles, avoiding asymmetry between detection and casting. + * + * @package KaririCode\Dotenv + * @since 4.0.0 ARFA 1.3 + */ +final readonly class BooleanDetector implements TypeDetector { - public const PRIORITY = 80; + private const array BOOLEAN_LITERALS = [ + 'true', 'false', 'yes', 'no', 'on', 'off', + ]; + + private const array BOOLEAN_PARENTHESIZED = ['(true)', '(false)']; - public function detect(mixed $value): ?string + #[\Override] + public function priority(): int { - if (is_bool($value)) { - return 'boolean'; - } - if (is_string($value)) { - $lower = strtolower($value); - if (in_array($lower, ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'], true)) { - return 'boolean'; - } + return 190; + } + + #[\Override] + public function detect(string $value): ?ValueType + { + if (\in_array(strtolower($value), self::BOOLEAN_LITERALS, true)) { + return ValueType::Boolean; } - return null; + return \in_array($value, self::BOOLEAN_PARENTHESIZED, true) ? ValueType::Boolean : null; } } diff --git a/src/Type/Detector/DotenvTypeDetectorRegistry.php b/src/Type/Detector/DotenvTypeDetectorRegistry.php deleted file mode 100644 index ca50971..0000000 --- a/src/Type/Detector/DotenvTypeDetectorRegistry.php +++ /dev/null @@ -1,61 +0,0 @@ -registerDefaultDetectors(); - } - - public function registerDetector(TypeDetector $detector): void - { - $this->detectors->add($detector); - $this->sortDetectors(); - } - - public function detectType(mixed $value): string - { - foreach ($this->detectors->getItems() as $detector) { - if ($type = $detector->detect($value)) { - return $type; - } - } - - return 'string'; // Fallback - } - - private function registerDefaultDetectors(): void - { - $defaultDetectors = [ - new ArrayDetector(), - new JsonDetector(), - new NullDetector(), - new BooleanDetector(), - new NumericDetector(), - new StringDetector(), - ]; - - foreach ($defaultDetectors as $detector) { - $this->registerDetector($detector); - } - } - - private function sortDetectors(): void - { - $detectors = $this->detectors->getItems(); - usort($detectors, fn (TypeDetector $a, TypeDetector $b) => $b->getPriority() - $a->getPriority()); - $this->detectors->clear(); - foreach ($detectors as $detector) { - $this->detectors->add($detector); - } - } -} diff --git a/src/Type/Detector/FloatDetector.php b/src/Type/Detector/FloatDetector.php new file mode 100644 index 0000000..a26a2d1 --- /dev/null +++ b/src/Type/Detector/FloatDetector.php @@ -0,0 +1,40 @@ +isStringInput($value)) { - return null; - } - - $cleanValue = $this->removeWhitespace($value); - - if ($this->isJsonObject($cleanValue)) { - return 'json'; - } - - if ($this->isJsonArrayOfObjects($cleanValue)) { - return 'json'; - } - - return null; - } - - private function isJsonObject(string $value): bool + #[\Override] + public function priority(): int { - return $this->isObjectFormat($value) && $this->isValidJson($value); + return 160; } - private function isJsonArrayOfObjects(string $value): bool + #[\Override] + public function detect(string $value): ?ValueType { - if (!$this->isArrayFormat($value)) { - return false; - } - - $decoded = json_decode($value, true); - if (JSON_ERROR_NONE !== json_last_error() || !is_array($decoded)) { - return false; - } - - if (empty($decoded)) { - return true; - } + $trimmed = trim($value); - foreach ($decoded as $item) { - if (!is_array($item) || $this->isSequentialArray($item)) { - return false; - } + if (! str_starts_with($trimmed, '{') || ! str_ends_with($trimmed, '}')) { + return null; } - return true; - } - - private function isObjectFormat(string $value): bool - { - return $this->hasDelimiters($value, '{', '}'); - } - - private function isArrayFormat(string $value): bool - { - return $this->hasDelimiters($value, '[', ']'); - } - - private function isValidJson(string $value): bool - { - json_decode($value); - - return JSON_ERROR_NONE === json_last_error(); - } + json_decode($trimmed); - private function isSequentialArray(array $arr): bool - { - return array_keys($arr) === range(0, count($arr) - 1); + return json_last_error() === JSON_ERROR_NONE ? ValueType::Json : null; } } diff --git a/src/Type/Detector/NullDetector.php b/src/Type/Detector/NullDetector.php index b1a3d53..4d939e9 100644 --- a/src/Type/Detector/NullDetector.php +++ b/src/Type/Detector/NullDetector.php @@ -4,16 +4,32 @@ namespace KaririCode\Dotenv\Type\Detector; -class NullDetector extends AbstractTypeDetector +use KaririCode\Dotenv\Contract\TypeDetector; +use KaririCode\Dotenv\Enum\ValueType; + +/** + * Detects null literals: "null", "NULL", "(null)". + * + * Empty string ('') is intentionally excluded — `FOO=` must yield an + * empty string, not null. This matches POSIX behavior and prevents + * data loss when distinguishing between `FOO=` and `FOO=null`. + * + * @package KaririCode\Dotenv + * @since 4.0.0 ARFA 1.3 + */ +final readonly class NullDetector implements TypeDetector { - public const PRIORITY = 95; + private const array NULL_LITERALS = ['null', 'NULL', '(null)']; - public function detect(mixed $value): ?string + #[\Override] + public function priority(): int { - if (null === $value || 'null' === strtolower($value) || '' === $value) { - return 'null'; - } + return 200; + } - return null; + #[\Override] + public function detect(string $value): ?ValueType + { + return \in_array($value, self::NULL_LITERALS, true) ? ValueType::Null : null; } } diff --git a/src/Type/Detector/NumericDetector.php b/src/Type/Detector/NumericDetector.php deleted file mode 100644 index f4c03c1..0000000 --- a/src/Type/Detector/NumericDetector.php +++ /dev/null @@ -1,19 +0,0 @@ -removeWhitespace($element))) { - return false; - } - } - - return true; - } -} diff --git a/src/Type/Detector/Trait/StringValidatorTrait.php b/src/Type/Detector/Trait/StringValidatorTrait.php deleted file mode 100644 index 246c3e2..0000000 --- a/src/Type/Detector/Trait/StringValidatorTrait.php +++ /dev/null @@ -1,23 +0,0 @@ -detectorRegistry->registerDetector($detector); - } - - public function registerCaster(string $type, TypeCaster $caster): void - { - $this->casterRegistry->register($type, $caster); - } - - public function processValue(mixed $value): mixed - { - $detectedType = $this->detectorRegistry->detectType($value); - - return $this->casterRegistry->cast($detectedType, $value); - } -} diff --git a/src/Type/TypeSystem.php b/src/Type/TypeSystem.php new file mode 100644 index 0000000..4fb07a3 --- /dev/null +++ b/src/Type/TypeSystem.php @@ -0,0 +1,130 @@ + Sorted by priority descending. */ + private array $detectors = []; + + private bool $sorted = false; + + /** @var array Keyed by ValueType->name. */ + private array $casters = []; + + public function __construct() + { + $this->registerDefaults(); + } + + public function addDetector(TypeDetector $detector): void + { + $this->detectors[] = $detector; + $this->sorted = false; + } + + public function addCaster(ValueType $type, TypeCaster $caster): void + { + $this->casters[$type->name] = $caster; + } + + public function detect(string $value): ValueType + { + $this->ensureSorted(); + + foreach ($this->detectors as $detector) { + $detected = $detector->detect($value); + if ($detected !== null) { + return $detected; + } + } + + return ValueType::String; + } + + public function cast(string $value, ValueType $type): mixed + { + if ($type === ValueType::String) { + return $value; + } + + $caster = $this->casters[$type->name] ?? null; + + return $caster !== null ? $caster->cast($value) : $value; + } + + public function resolve(string $value): mixed + { + $type = $this->detect($value); + + return $this->cast($value, $type); + } + + // ── Internal ────────────────────────────────────────────────────── + + private function registerDefaults(): void + { + $this->detectors = [ + new NullDetector(), + new BooleanDetector(), + new IntegerDetector(), + new FloatDetector(), + new JsonDetector(), + new ArrayDetector(), + ]; + $this->sorted = false; + + $this->casters = [ + ValueType::Null->name => new NullCaster(), + ValueType::Boolean->name => new BooleanCaster(), + ValueType::Integer->name => new IntegerCaster(), + ValueType::Float->name => new FloatCaster(), + ValueType::Json->name => new JsonCaster(), + ValueType::Array->name => new ArrayCaster(), + ]; + } + + private function ensureSorted(): void + { + if ($this->sorted) { + return; + } + + usort($this->detectors, static fn (TypeDetector $a, TypeDetector $b): int => $b->priority() <=> $a->priority()); + $this->sorted = true; + } +} diff --git a/src/Validation/EnvironmentValidator.php b/src/Validation/EnvironmentValidator.php new file mode 100644 index 0000000..d84c5bd --- /dev/null +++ b/src/Validation/EnvironmentValidator.php @@ -0,0 +1,256 @@ +validate() + * ->required('DB_HOST', 'DB_PORT') + * ->notEmpty('DB_HOST') + * ->isInteger('DB_PORT')->between(1, 65535) + * ->isBoolean('APP_DEBUG') + * ->allowedValues('APP_ENV', ['local', 'staging', 'production']) + * ->ifPresent('REDIS_HOST')->notEmpty() + * ->assert(); + * ``` + * + * @package KaririCode\Dotenv + * @since 4.1.0 + */ +final class EnvironmentValidator +{ + /** @var array> Rules keyed by variable name. */ + private array $rules = []; + + /** @var list Variables that must exist. */ + private array $requiredNames = []; + + /** @var list Current target variable names for fluent chaining. */ + private array $currentTargets = []; + + /** @var bool When true, rules only apply if the variable exists. */ + private bool $conditionalMode = false; + + /** + * @param \Closure(string): ?string $valueResolver Returns raw value or null if not set. + */ + public function __construct( + private readonly \Closure $valueResolver, + ) { + } + + // ── Targeting ───────────────────────────────────────────────────── + + /** + * Declares variables that must exist (non-null). Does not apply rules to them + * unless followed by chained rule methods. + */ + public function required(string ...$names): self + { + $this->requiredNames = array_values(array_merge($this->requiredNames, $names)); + $this->currentTargets = array_values($names); + $this->conditionalMode = false; + + return $this; + } + + /** + * Targets variables for rule application. If a variable is absent, + * validation for it is silently skipped. + */ + public function ifPresent(string ...$names): self + { + $this->currentTargets = array_values($names); + $this->conditionalMode = true; + + return $this; + } + + // ── Built-in Rules ──────────────────────────────────────────────── + + public function notEmpty(string ...$names): self + { + return $this->applyRule(new NotEmptyRule(), array_values($names)); + } + + public function isInteger(string ...$names): self + { + return $this->applyRule(new IsIntegerRule(), array_values($names)); + } + + public function isBoolean(string ...$names): self + { + return $this->applyRule(new IsBooleanRule(), array_values($names)); + } + + public function isNumeric(string ...$names): self + { + return $this->applyRule(new IsNumericRule(), array_values($names)); + } + + /** + * Applies a numeric range constraint. Requires prior isInteger() or isNumeric(). + * Always targets the current chain (set by required/ifPresent/isInteger/etc). + */ + public function between(int|float $min, int|float $max): self + { + return $this->applyRule(new BetweenRule($min, $max), []); + } + + /** + * @param list $allowed Accepted values for this variable. + */ + public function allowedValues(string $name, array $allowed): self + { + $this->currentTargets = [$name]; + + return $this->applyRule(new AllowedValuesRule($allowed), []); + } + + public function matchesRegex(string $name, string $pattern): self + { + $this->currentTargets = [$name]; + + return $this->applyRule(new MatchesRegexRule($pattern), []); + } + + public function url(string ...$names): self + { + return $this->applyRule(new UrlRule(), array_values($names)); + } + + public function email(string ...$names): self + { + return $this->applyRule(new EmailRule(), array_values($names)); + } + + /** + * @param \Closure(string): bool $callback + */ + public function custom(string $name, \Closure $callback, string $message = ''): self + { + $this->currentTargets = [$name]; + $msg = $message !== '' ? $message : '{name} failed custom validation.'; + + return $this->applyRule(new CustomRule($callback, $msg), []); + } + + /** + * Adds an arbitrary ValidationRule implementation to the current targets. + */ + public function rule(ValidationRule $rule, string ...$names): self + { + return $this->applyRule($rule, array_values($names)); + } + + // ── Execution ───────────────────────────────────────────────────── + + /** + * Runs all collected rules and throws with ALL failures if any exist. + * + * @throws ValidationException Containing every failure message. + */ + public function assert(): void + { + $errors = []; + + // Check required presence first + $missingRequired = []; + foreach (array_unique($this->requiredNames) as $name) { + $value = ($this->valueResolver)($name); + if ($value === null) { + $missingRequired[] = $name; + } + } + + if ($missingRequired !== []) { + foreach ($missingRequired as $name) { + $errors[] = "{$name} is required but not defined."; + } + } + + // Run rules per variable + foreach ($this->rules as $name => $ruleList) { + $value = ($this->valueResolver)($name); + + // Skip absent variables in conditional mode + if ($value === null) { + if ($this->isConditional($name)) { + continue; + } + + // Already reported as missing-required above, don't run rules + if (\in_array($name, $this->requiredNames, true)) { + continue; + } + + // Not required, not conditional — skip silently + continue; + } + + foreach ($ruleList as $rule) { + if (! $rule->passes($value)) { + $errors[] = str_replace('{name}', $name, $rule->message()); + } + } + } + + if ($errors !== []) { + throw ValidationException::batchErrors($errors); + } + } + + // ── Internal ────────────────────────────────────────────────────── + + /** @var array Variables registered via ifPresent(). */ + private array $conditionalNames = []; + + private function isConditional(string $name): bool + { + return isset($this->conditionalNames[$name]); + } + + /** + * @param list $explicitNames Override current targets when non-empty. + */ + private function applyRule(ValidationRule $rule, array $explicitNames): self + { + $targets = $explicitNames !== [] ? $explicitNames : $this->currentTargets; + + if ($explicitNames !== []) { + $this->currentTargets = $explicitNames; + } + + if ($this->conditionalMode) { + foreach ($targets as $name) { + $this->conditionalNames[$name] = true; + } + } + + foreach ($targets as $name) { + $this->rules[$name][] = $rule; + } + + return $this; + } +} diff --git a/src/Validation/Rule/AllowedValuesRule.php b/src/Validation/Rule/AllowedValuesRule.php new file mode 100644 index 0000000..1cd8ce0 --- /dev/null +++ b/src/Validation/Rule/AllowedValuesRule.php @@ -0,0 +1,30 @@ + $allowed */ + public function __construct( + private array $allowed, + ) { + } + + #[\Override] + public function passes(string $value): bool + { + return \in_array($value, $this->allowed, true); + } + + #[\Override] + public function message(): string + { + $list = implode(', ', $this->allowed); + + return "{name} must be one of: {$list}."; + } +} diff --git a/src/Validation/Rule/BetweenRule.php b/src/Validation/Rule/BetweenRule.php new file mode 100644 index 0000000..e09c1e6 --- /dev/null +++ b/src/Validation/Rule/BetweenRule.php @@ -0,0 +1,34 @@ += $this->min && $numeric <= $this->max; + } + + #[\Override] + public function message(): string + { + return "{name} must be between {$this->min} and {$this->max}."; + } +} diff --git a/src/Validation/Rule/CustomRule.php b/src/Validation/Rule/CustomRule.php new file mode 100644 index 0000000..d181a4d --- /dev/null +++ b/src/Validation/Rule/CustomRule.php @@ -0,0 +1,29 @@ +callback)($value); + } + + #[\Override] + public function message(): string + { + return $this->failureMessage; + } +} diff --git a/src/Validation/Rule/EmailRule.php b/src/Validation/Rule/EmailRule.php new file mode 100644 index 0000000..99018ed --- /dev/null +++ b/src/Validation/Rule/EmailRule.php @@ -0,0 +1,22 @@ +pattern, $value) === 1; + } + + #[\Override] + public function message(): string + { + return "{name} must match pattern {$this->pattern}."; + } +} diff --git a/src/Validation/Rule/NotEmptyRule.php b/src/Validation/Rule/NotEmptyRule.php new file mode 100644 index 0000000..76e8ab6 --- /dev/null +++ b/src/Validation/Rule/NotEmptyRule.php @@ -0,0 +1,22 @@ + $allowList Glob patterns for allowed variable names (empty = all). + * @param list $denyList Glob patterns for denied variable names. + * @param string|null $environmentName Environment name for cascade loading (e.g., "production"). + */ + public function __construct( + public LoadMode $loadMode = LoadMode::Immutable, + public bool $strictNames = false, + public bool $typeCasting = true, + public bool $populateEnv = true, + public bool $populateServer = true, + public bool $usePutenv = false, + public ?string $encryptionKey = null, + public ?string $cachePath = null, + public array $allowList = [], + public array $denyList = [], + public ?string $environmentName = null, + ) { + } + + public function withLoadMode(LoadMode $loadMode): self + { + return new self( + loadMode: $loadMode, + strictNames: $this->strictNames, + typeCasting: $this->typeCasting, + populateEnv: $this->populateEnv, + populateServer: $this->populateServer, + usePutenv: $this->usePutenv, + encryptionKey: $this->encryptionKey, + cachePath: $this->cachePath, + allowList: $this->allowList, + denyList: $this->denyList, + environmentName: $this->environmentName, + ); + } + + public function withStrictNames(bool $strict): self + { + return new self( + loadMode: $this->loadMode, + strictNames: $strict, + typeCasting: $this->typeCasting, + populateEnv: $this->populateEnv, + populateServer: $this->populateServer, + usePutenv: $this->usePutenv, + encryptionKey: $this->encryptionKey, + cachePath: $this->cachePath, + allowList: $this->allowList, + denyList: $this->denyList, + environmentName: $this->environmentName, + ); + } + + public function withTypeCasting(bool $enabled): self + { + return new self( + loadMode: $this->loadMode, + strictNames: $this->strictNames, + typeCasting: $enabled, + populateEnv: $this->populateEnv, + populateServer: $this->populateServer, + usePutenv: $this->usePutenv, + encryptionKey: $this->encryptionKey, + cachePath: $this->cachePath, + allowList: $this->allowList, + denyList: $this->denyList, + environmentName: $this->environmentName, + ); + } + + public function withEncryptionKey(?string $key): self + { + return new self( + loadMode: $this->loadMode, + strictNames: $this->strictNames, + typeCasting: $this->typeCasting, + populateEnv: $this->populateEnv, + populateServer: $this->populateServer, + usePutenv: $this->usePutenv, + encryptionKey: $key, + cachePath: $this->cachePath, + allowList: $this->allowList, + denyList: $this->denyList, + environmentName: $this->environmentName, + ); + } + + public function withCachePath(?string $path): self + { + return new self( + loadMode: $this->loadMode, + strictNames: $this->strictNames, + typeCasting: $this->typeCasting, + populateEnv: $this->populateEnv, + populateServer: $this->populateServer, + usePutenv: $this->usePutenv, + encryptionKey: $this->encryptionKey, + cachePath: $path, + allowList: $this->allowList, + denyList: $this->denyList, + environmentName: $this->environmentName, + ); + } + + /** + * Return a new instance with the given allow-list glob patterns. + * + * @param list $patterns Glob patterns for allowed variable names. + * @since 4.0.0 + */ + public function withAllowList(array $patterns): self + { + return new self( + loadMode: $this->loadMode, + strictNames: $this->strictNames, + typeCasting: $this->typeCasting, + populateEnv: $this->populateEnv, + populateServer: $this->populateServer, + usePutenv: $this->usePutenv, + encryptionKey: $this->encryptionKey, + cachePath: $this->cachePath, + allowList: $patterns, + denyList: $this->denyList, + environmentName: $this->environmentName, + ); + } + + /** + * Return a new instance with the given deny-list glob patterns. + * + * @param list $patterns Glob patterns for denied variable names. + * @since 4.0.0 + */ + public function withDenyList(array $patterns): self + { + return new self( + loadMode: $this->loadMode, + strictNames: $this->strictNames, + typeCasting: $this->typeCasting, + populateEnv: $this->populateEnv, + populateServer: $this->populateServer, + usePutenv: $this->usePutenv, + encryptionKey: $this->encryptionKey, + cachePath: $this->cachePath, + allowList: $this->allowList, + denyList: $patterns, + environmentName: $this->environmentName, + ); + } + + public function withEnvironmentName(?string $name): self + { + return new self( + loadMode: $this->loadMode, + strictNames: $this->strictNames, + typeCasting: $this->typeCasting, + populateEnv: $this->populateEnv, + populateServer: $this->populateServer, + usePutenv: $this->usePutenv, + encryptionKey: $this->encryptionKey, + cachePath: $this->cachePath, + allowList: $this->allowList, + denyList: $this->denyList, + environmentName: $name, + ); + } +} diff --git a/src/ValueObject/EnvironmentVariable.php b/src/ValueObject/EnvironmentVariable.php new file mode 100644 index 0000000..d0e7e95 --- /dev/null +++ b/src/ValueObject/EnvironmentVariable.php @@ -0,0 +1,37 @@ +resolve($value); +} diff --git a/src/functions.php b/src/functions.php deleted file mode 100644 index de12ebb..0000000 --- a/src/functions.php +++ /dev/null @@ -1,18 +0,0 @@ -parser = $this->createMock(Parser::class); - $this->loader = $this->createMock(Loader::class); - $this->typeSystem = $this->createMock(TypeSystem::class); - $this->dotenv = new Dotenv($this->parser, $this->loader, $this->typeSystem); - } - - public function testLoad(): void - { - $envContent = "KEY1=value1\nKEY2=value2"; - $parsedContent = ['KEY1' => 'value1', 'KEY2' => 'value2']; - - $this->loader->expects($this->once()) - ->method('load') - ->willReturn($envContent); - - $this->parser->expects($this->once()) - ->method('parse') - ->with($envContent) - ->willReturn($parsedContent); - - $this->typeSystem->expects($this->exactly(2)) - ->method('processValue') - ->willReturnArgument(0); - - $this->dotenv->load(); - - $this->assertSame('value1', $_ENV['KEY1']); - $this->assertSame('value2', $_ENV['KEY2']); - $this->assertSame('value1', $_SERVER['KEY1']); - $this->assertSame('value2', $_SERVER['KEY2']); - } - - public function testAddTypeDetector(): void - { - $detector = $this->createMock(TypeDetector::class); - - $this->typeSystem->expects($this->once()) - ->method('registerDetector') - ->with($detector); - - $result = $this->dotenv->addTypeDetector($detector); - - $this->assertSame($this->dotenv, $result); - } - - public function testAddTypeCaster(): void - { - $type = 'custom_type'; - $caster = $this->createMock(TypeCaster::class); - - $this->typeSystem->expects($this->once()) - ->method('registerCaster') - ->with($type, $caster); - - $result = $this->dotenv->addTypeCaster($type, $caster); - - $this->assertSame($this->dotenv, $result); - } - - public function testLoadWithTypeProcessing(): void - { - $envContent = "KEY1=true\nKEY2=123"; - $parsedContent = ['KEY1' => 'true', 'KEY2' => '123']; - - $this->loader->expects($this->once()) - ->method('load') - ->willReturn($envContent); - - $this->parser->expects($this->once()) - ->method('parse') - ->with($envContent) - ->willReturn($parsedContent); - - $this->typeSystem->expects($this->exactly(2)) - ->method('processValue') - ->willReturnMap([ - ['true', true], - ['123', 123], - ]); - - $this->dotenv->load(); - - $this->assertSame(true, $_ENV['KEY1']); - $this->assertSame(123, $_ENV['KEY2']); - $this->assertSame(true, $_SERVER['KEY1']); - $this->assertSame(123, $_SERVER['KEY2']); - } - - public function testLoadWithInvalidValue(): void - { - $envContent = 'INVALID_KEY=invalid_value'; - $parsedContent = ['INVALID_KEY' => 'invalid_value']; - - $this->loader->expects($this->once()) - ->method('load') - ->willReturn($envContent); - - $this->parser->expects($this->once()) - ->method('parse') - ->with($envContent) - ->willReturn($parsedContent); - - $this->typeSystem->expects($this->once()) - ->method('processValue') - ->willThrowException(new InvalidValueException('Invalid value')); - - $this->expectException(InvalidValueException::class); - $this->expectExceptionMessage('Invalid value'); - - $this->dotenv->load(); - } - - public function testLoadWithEmptyContent(): void - { - $envContent = ''; - $parsedContent = []; - - $this->loader->expects($this->once()) - ->method('load') - ->willReturn($envContent); - - $this->parser->expects($this->once()) - ->method('parse') - ->with($envContent) - ->willReturn($parsedContent); - - $this->typeSystem->expects($this->never()) - ->method('processValue'); - - $initialEnv = $_ENV; - $initialServer = $_SERVER; - - $this->dotenv->load(); - - $this->assertSame($initialEnv, $_ENV); - $this->assertSame($initialServer, $_SERVER); - } - - protected function tearDown(): void - { - parent::tearDown(); - $_ENV = []; - $_SERVER = []; - } -} diff --git a/tests/Integration/DotenvIntegrationTest.php b/tests/Integration/DotenvIntegrationTest.php index 072b8ea..d25b95c 100644 --- a/tests/Integration/DotenvIntegrationTest.php +++ b/tests/Integration/DotenvIntegrationTest.php @@ -4,98 +4,638 @@ namespace KaririCode\Dotenv\Tests\Integration; -use KaririCode\Dotenv\Contract\Type\TypeCaster; -use KaririCode\Dotenv\Contract\Type\TypeDetector; -use KaririCode\Dotenv\DotenvFactory; +use KaririCode\Dotenv\Dotenv; +use KaririCode\Dotenv\Enum\LoadMode; + +use function KaririCode\Dotenv\env; + +use KaririCode\Dotenv\Exception\FileNotFoundException; +use KaririCode\Dotenv\Exception\ValidationException; +use KaririCode\Dotenv\Processor\CsvToArrayProcessor; +use KaririCode\Dotenv\Processor\UrlNormalizerProcessor; +use KaririCode\Dotenv\Security\Encryptor; +use KaririCode\Dotenv\Security\KeyPair; +use KaririCode\Dotenv\ValueObject\DotenvConfiguration; use PHPUnit\Framework\TestCase; final class DotenvIntegrationTest extends TestCase { - private string $envFile; + private string $fixturesDir; + + /** @var array $_ENV snapshot before each test. */ + private array $envSnapshot; + + /** @var array $_SERVER snapshot before each test. */ + private array $serverSnapshot; protected function setUp(): void { - parent::setUp(); - $this->envFile = sys_get_temp_dir() . '/test_' . uniqid() . '.env'; + $this->fixturesDir = sys_get_temp_dir() . '/kariricode-dotenv-test-' . uniqid(); + mkdir($this->fixturesDir, 0o755, true); + + // Snapshot environment to restore in tearDown + $this->envSnapshot = $_ENV; + $this->serverSnapshot = $_SERVER; } protected function tearDown(): void { - parent::tearDown(); - if (file_exists($this->envFile)) { - unlink($this->envFile); + // Restore $_ENV and $_SERVER to pre-test state + $addedEnvKeys = array_diff_key($_ENV, $this->envSnapshot); + foreach ($addedEnvKeys as $key => $_) { + unset($_ENV[$key]); + putenv($key); + } + + $addedServerKeys = array_diff_key($_SERVER, $this->serverSnapshot); + foreach ($addedServerKeys as $key => $_) { + unset($_SERVER[$key]); } + + // Restore any values that were overwritten + $_ENV = $this->envSnapshot; + $_SERVER = $this->serverSnapshot; + + $this->removeDirectory($this->fixturesDir); + } + + // ── Loading ─────────────────────────────────────────────────────── + + public function testLoadsEnvFile(): void + { + $this->createEnvFile('.env', <<<'ENV' + TEST_STRING=hello world + TEST_INT=42 + TEST_FLOAT=3.14 + TEST_BOOL=true + TEST_NULL=null + TEST_JSON={"key": "value"} + TEST_ARRAY=["a", "b", "c"] + ENV); + + $dotenv = new Dotenv($this->fixturesDir); + $dotenv->load(); + + self::assertTrue($dotenv->isLoaded()); + + // Typed values via get() + self::assertSame('hello world', $dotenv->get('TEST_STRING')); + self::assertSame(42, $dotenv->get('TEST_INT')); + self::assertSame(3.14, $dotenv->get('TEST_FLOAT')); + self::assertTrue($dotenv->get('TEST_BOOL')); + self::assertNull($dotenv->get('TEST_NULL')); + self::assertSame(['key' => 'value'], $dotenv->get('TEST_JSON')); + self::assertSame(['a', 'b', 'c'], $dotenv->get('TEST_ARRAY')); + + // Raw values in $_ENV + self::assertSame('42', $_ENV['TEST_INT']); + self::assertSame('true', $_ENV['TEST_BOOL']); + } + + public function testEnvHelperFunction(): void + { + $this->createEnvFile('.env', <<<'ENV' + TEST_INT=99 + TEST_BOOL=false + ENV); + + $dotenv = new Dotenv($this->fixturesDir); + $dotenv->load(); + + self::assertSame(99, env('TEST_INT')); + self::assertFalse(env('TEST_BOOL')); + self::assertSame('default', env('NONEXISTENT', 'default')); + } + + public function testVariableInterpolation(): void + { + $this->createEnvFile('.env', <<<'ENV' + APP_NAME=KaririCode + GREETING="Welcome to ${APP_NAME}" + ENV); + + $dotenv = new Dotenv($this->fixturesDir); + $dotenv->load(); + + self::assertSame('Welcome to KaririCode', $dotenv->get('GREETING')); + } + + // ── Load Modes ──────────────────────────────────────────────────── + + public function testSkipExistingMode(): void + { + $_ENV['IMMUTABLE_VAR'] = 'original'; + + $this->createEnvFile('.env', "IMMUTABLE_VAR=overwritten"); + + $config = new DotenvConfiguration(loadMode: LoadMode::SkipExisting); + $dotenv = new Dotenv($this->fixturesDir, $config); + $dotenv->load(); + + self::assertSame('original', $_ENV['IMMUTABLE_VAR']); + } + + public function testOverwriteMode(): void + { + $_ENV['IMMUTABLE_VAR'] = 'original'; + + $this->createEnvFile('.env', "IMMUTABLE_VAR=overwritten"); + + $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite); + $dotenv = new Dotenv($this->fixturesDir, $config); + $dotenv->load(); + + self::assertSame('overwritten', $_ENV['IMMUTABLE_VAR']); + } + + // ── Validation ──────────────────────────────────────────────────── + + public function testRequiredVariablesPass(): void + { + $this->createEnvFile('.env', "FOO=bar\nBAZ=qux"); + + $dotenv = new Dotenv($this->fixturesDir); + $dotenv->load(); + + // Should not throw + $dotenv->required('FOO', 'BAZ'); + + self::assertTrue(true); // Reached without exception + } + + public function testRequiredVariablesThrowOnMissing(): void + { + $this->createEnvFile('.env', 'FOO=bar'); + + $dotenv = new Dotenv($this->fixturesDir); + $dotenv->load(); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('MISSING_VAR'); + + $dotenv->required('FOO', 'MISSING_VAR'); + } + + // ── Error Handling ──────────────────────────────────────────────── + + public function testThrowsOnMissingFile(): void + { + $this->expectException(FileNotFoundException::class); + + $dotenv = new Dotenv($this->fixturesDir . '/nonexistent'); + $dotenv->load(); + } + + public function testSafeLoadSkipsMissingFile(): void + { + $dotenv = new Dotenv($this->fixturesDir . '/nonexistent'); + $dotenv->safeLoad(); + + self::assertTrue($dotenv->isLoaded()); + self::assertSame([], $dotenv->variables()); + } + + // ── Multiple Files ──────────────────────────────────────────────── + + public function testLoadsMultipleFiles(): void + { + $this->createEnvFile('.env', "FOO=base\nBAR=base"); + $this->createEnvFile('.env.local', 'BAR=override'); + + $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite); + $dotenv = new Dotenv($this->fixturesDir, $config, '.env', '.env.local'); + $dotenv->load(); + + self::assertSame('base', $dotenv->get('FOO')); + self::assertSame('override', $dotenv->get('BAR')); + } + + // ── Configuration ───────────────────────────────────────────────── + + public function testDisableTypeCasting(): void + { + $this->createEnvFile('.env', 'TEST_INT=42'); + + $config = new DotenvConfiguration(typeCasting: false); + $dotenv = new Dotenv($this->fixturesDir, $config); + $dotenv->load(); + + self::assertSame('42', $dotenv->get('TEST_INT')); + } + + // ── Fluent Validation DSL ───────────────────────────────────────── + + public function testValidationDslPasses(): void + { + $this->createEnvFile('.env', <<<'ENV' + DB_HOST=localhost + DB_PORT=5432 + APP_DEBUG=true + APP_ENV=production + APP_URL=https://example.com + ADMIN_EMAIL=admin@example.com + ENV); + + $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite); + $dotenv = new Dotenv($this->fixturesDir, $config); + $dotenv->load(); + + $dotenv->validate() + ->required('DB_HOST', 'DB_PORT') + ->notEmpty('DB_HOST') + ->isInteger('DB_PORT')->between(1, 65535) + ->isBoolean('APP_DEBUG') + ->allowedValues('APP_ENV', ['local', 'staging', 'production']) + ->url('APP_URL') + ->email('ADMIN_EMAIL') + ->assert(); + + self::assertTrue(true); + } + + public function testValidationDslCollectsAllErrors(): void + { + $this->createEnvFile('.env', <<<'ENV' + DB_PORT=invalid + APP_ENV=wrong + ENV); + + $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite); + $dotenv = new Dotenv($this->fixturesDir, $config); + $dotenv->load(); + + try { + $dotenv->validate() + ->required('DB_HOST', 'DB_PORT') + ->isInteger('DB_PORT') + ->allowedValues('APP_ENV', ['local', 'staging', 'production']) + ->assert(); + self::fail('Expected ValidationException'); + } catch (ValidationException $e) { + // DB_HOST missing + DB_PORT not integer + APP_ENV not in allowed + self::assertCount(3, $e->errors()); + } + } + + public function testValidationIfPresentSkipsMissing(): void + { + $this->createEnvFile('.env', 'DB_HOST=localhost'); + + $dotenv = new Dotenv($this->fixturesDir); + $dotenv->load(); + + $dotenv->validate() + ->ifPresent('REDIS_HOST')->notEmpty() + ->assert(); + + self::assertTrue(true); + } + + public function testValidationCustomCallback(): void + { + $this->createEnvFile('.env', 'DB_HOST=pgsql:host=localhost'); + + $dotenv = new Dotenv($this->fixturesDir); + $dotenv->load(); + + $dotenv->validate() + ->custom('DB_HOST', fn (string $v): bool => str_starts_with($v, 'pgsql:')) + ->assert(); + + self::assertTrue(true); + } + + // ── Encryption ──────────────────────────────────────────────────── + + public function testEncryptionRoundTrip(): void + { + $keyPair = KeyPair::generate(); + $encryptor = new Encryptor($keyPair->privateKey); + + // Create .env with encrypted value + $encryptedSecret = $encryptor->encrypt('my-secret-password'); + $this->createEnvFile('.env', "SECRET={$encryptedSecret}\nFOO=plain"); + + $config = new DotenvConfiguration(encryptionKey: $keyPair->privateKey); + $dotenv = new Dotenv($this->fixturesDir, $config); + $dotenv->load(); + + self::assertSame('my-secret-password', $dotenv->get('SECRET')); + self::assertSame('plain', $dotenv->get('FOO')); + + // $_ENV should also have decrypted value + self::assertSame('my-secret-password', $_ENV['SECRET']); } - public function testLoadEnvironmentVariables(): void + public function testPlaintextValuesPassThroughWithEncryptionEnabled(): void { - $envContent = <<envFile, $envContent); + $keyPair = KeyPair::generate(); - $dotenv = DotenvFactory::create($this->envFile); + $this->createEnvFile('.env', 'FOO=plaintext-value'); + + $config = new DotenvConfiguration(encryptionKey: $keyPair->privateKey); + $dotenv = new Dotenv($this->fixturesDir, $config); $dotenv->load(); - $this->assertSame('Hello World', $_ENV['STRING_VAR']); - $this->assertSame(42, $_ENV['INT_VAR']); - $this->assertSame(3.14, $_ENV['FLOAT_VAR']); - $this->assertSame(true, $_ENV['BOOL_VAR']); - $this->assertNull($_ENV['NULL_VAR']); - $this->assertSame(['1', '2', '3'], $_ENV['ARRAY_VAR']); - $this->assertSame(['key' => 'value'], $_ENV['JSON_VAR']); + self::assertSame('plaintext-value', $dotenv->get('FOO')); + } + + // ── Cache ───────────────────────────────────────────────────────── + + public function testCacheDumpAndLoad(): void + { + $this->createEnvFile('.env', "DB_HOST=localhost\nDB_PORT=5432"); + + $cachePath = $this->fixturesDir . '/.env.cache.php'; + + // First: load and dump cache + $dotenv1 = new Dotenv($this->fixturesDir); + $dotenv1->load(); + $dotenv1->dumpCache($cachePath); + + self::assertFileExists($cachePath); + + // Clean env for second load + unset($_ENV['DB_HOST'], $_ENV['DB_PORT'], $_SERVER['DB_HOST'], $_SERVER['DB_PORT']); + putenv('DB_HOST'); + putenv('DB_PORT'); + + // Second: load from cache + $config = new DotenvConfiguration( + loadMode: LoadMode::Overwrite, + cachePath: $cachePath, + ); + $dotenv2 = new Dotenv($this->fixturesDir, $config); + $dotenv2->load(); + + self::assertSame('localhost', $dotenv2->get('DB_HOST')); + self::assertSame(5432, $dotenv2->get('DB_PORT')); + } + + public function testCacheClear(): void + { + $cachePath = $this->fixturesDir . '/.env.cache.php'; + file_put_contents($cachePath, 'fixturesDir); + $dotenv->clearCache($cachePath); + + self::assertFileDoesNotExist($cachePath); + } + + // ── Environment-Aware Loading (bootEnv) ─────────────────────────── + + public function testBootEnvCascadeLoading(): void + { + $this->createEnvFile('.env', "APP_ENV=staging\nDB_HOST=base-host\nDB_PORT=5432"); + $this->createEnvFile('.env.local', 'DB_HOST=local-host'); + $this->createEnvFile('.env.staging', 'DB_PORT=5433'); + $this->createEnvFile('.env.staging.local', 'DB_NAME=staging_local_db'); + + $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite); + $dotenv = new Dotenv($this->fixturesDir, $config); + $dotenv->bootEnv(); + + // base → .env.local overrides DB_HOST → .env.staging overrides DB_PORT + self::assertSame('local-host', $dotenv->get('DB_HOST')); + self::assertSame(5433, $dotenv->get('DB_PORT')); + self::assertSame('staging_local_db', $dotenv->get('DB_NAME')); + } + + public function testBootEnvWithExplicitEnvironment(): void + { + $this->createEnvFile('.env', "DB_HOST=base\nAPP_ENV=ignored"); + $this->createEnvFile('.env.production', 'DB_HOST=prod-host'); + + $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite); + $dotenv = new Dotenv($this->fixturesDir, $config); + $dotenv->bootEnv('production'); + + self::assertSame('prod-host', $dotenv->get('DB_HOST')); + } + + public function testBootEnvSkipsTestLocalFile(): void + { + $this->createEnvFile('.env', 'APP_ENV=test'); + $this->createEnvFile('.env.test', 'DB_HOST=test-host'); + $this->createEnvFile('.env.test.local', 'DB_HOST=should-not-load'); + + $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite); + $dotenv = new Dotenv($this->fixturesDir, $config); + $dotenv->bootEnv('test'); + + // .env.test.local is skipped for "test" environment + self::assertSame('test-host', $dotenv->get('DB_HOST')); } - public function testCustomTypeDetectorAndCaster(): void + public function testBootEnvReadsAppEnvFromEnvironment(): void { - $envContent = 'CUSTOM_VAR=custom_value'; - file_put_contents($this->envFile, $envContent); + $_ENV['APP_ENV'] = 'production'; + + $this->createEnvFile('.env', 'DB_HOST=base'); + $this->createEnvFile('.env.production', 'DB_HOST=prod-host'); - $dotenv = DotenvFactory::create($this->envFile); + $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite); + $dotenv = new Dotenv($this->fixturesDir, $config); + $dotenv->bootEnv(); + + self::assertSame('prod-host', $dotenv->get('DB_HOST')); + } + + // ── Allow/Deny List ─────────────────────────────────────────────── + + public function testAllowListFilters(): void + { + $this->createEnvFile('.env', "DB_HOST=localhost\nDB_PORT=5432\nSECRET=hidden\nAPI_KEY=abc123"); - $customDetector = new class implements TypeDetector { - public function detect(mixed $value): ?string - { - return 'custom_value' === $value ? 'custom' : null; - } + $config = new DotenvConfiguration( + loadMode: LoadMode::Overwrite, + allowList: ['DB_*'], + ); + $dotenv = new Dotenv($this->fixturesDir, $config); + $dotenv->load(); - public function getPriority(): int - { - return 1000; - } - }; + self::assertSame('localhost', $dotenv->get('DB_HOST')); + self::assertSame(5432, $dotenv->get('DB_PORT')); + self::assertNull($dotenv->get('SECRET')); + self::assertNull($dotenv->get('API_KEY')); + } - $customCaster = new class implements TypeCaster { - public function cast(mixed $value): string - { - return strtoupper((string) $value); - } - }; + public function testDenyListFilters(): void + { + $this->createEnvFile('.env', "DB_HOST=localhost\nSECRET=hidden\nAPI_KEY=abc123"); - $dotenv->addTypeDetector($customDetector); - $dotenv->addTypeCaster('custom', $customCaster); + $config = new DotenvConfiguration( + loadMode: LoadMode::Overwrite, + denyList: ['SECRET*'], + ); + $dotenv = new Dotenv($this->fixturesDir, $config); $dotenv->load(); - $this->assertSame('CUSTOM_VALUE', $_ENV['CUSTOM_VAR']); + self::assertSame('localhost', $dotenv->get('DB_HOST')); + self::assertSame('abc123', $dotenv->get('API_KEY')); + self::assertNull($dotenv->get('SECRET')); } - public function testInterpolation(): void + // ── Processors ──────────────────────────────────────────────────── + + public function testCsvProcessorSplitsValues(): void { - $envContent = <<envFile, $envContent); + $this->createEnvFile('.env', 'ALLOWED_IPS=192.168.1.1, 10.0.0.1, 172.16.0.1'); - $dotenv = DotenvFactory::create($this->envFile); + $dotenv = new Dotenv($this->fixturesDir); + $dotenv->addProcessor('ALLOWED_IPS', new CsvToArrayProcessor()); $dotenv->load(); - $this->assertSame('http://example.com/api', $_ENV['API_URL']); + self::assertSame( + ['192.168.1.1', '10.0.0.1', '172.16.0.1'], + $dotenv->get('ALLOWED_IPS'), + ); + } + + public function testGlobPatternProcessor(): void + { + $this->createEnvFile('.env', "APP_URL=https://example.com\nAPI_KEY=secret"); + + $dotenv = new Dotenv($this->fixturesDir); + $dotenv->addProcessor('*_URL', new UrlNormalizerProcessor()); + $dotenv->load(); + + self::assertSame('https://example.com/', $dotenv->get('APP_URL')); + self::assertSame('secret', $dotenv->get('API_KEY')); // Not affected + } + + // ── Schema Validation ───────────────────────────────────────────── + + public function testSchemaValidationPasses(): void + { + $this->createEnvFile('.env', "DB_HOST=localhost\nDB_PORT=5432\nAPP_ENV=production"); + + $schemaPath = $this->fixturesDir . '/.env.schema'; + file_put_contents($schemaPath, <<<'SCHEMA' + [DB_HOST] + required = true + notEmpty = true + + [DB_PORT] + required = true + type = integer + min = 1 + max = 65535 + + [APP_ENV] + required = true + allowed = local, staging, production + SCHEMA); + + $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite); + $dotenv = new Dotenv($this->fixturesDir, $config); + $dotenv->loadWithSchema($schemaPath); + + self::assertTrue(true); + } + + public function testSchemaValidationFails(): void + { + $this->createEnvFile('.env', "DB_PORT=invalid\nAPP_ENV=wrong"); + + $schemaPath = $this->fixturesDir . '/.env.schema'; + file_put_contents($schemaPath, <<<'SCHEMA' + [DB_HOST] + required = true + + [DB_PORT] + required = true + type = integer + + [APP_ENV] + allowed = local, staging, production + SCHEMA); + + $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite); + $dotenv = new Dotenv($this->fixturesDir, $config); + + $this->expectException(ValidationException::class); + + $dotenv->loadWithSchema($schemaPath); + } + + // ── Debug / Introspection ───────────────────────────────────────── + + public function testDebugReturnsSourceTracking(): void + { + $this->createEnvFile('.env', "DB_HOST=localhost\nDB_PORT=5432"); + + $dotenv = new Dotenv($this->fixturesDir); + $dotenv->load(); + + $debug = $dotenv->debug(); + + self::assertArrayHasKey('DB_HOST', $debug); + self::assertSame('.env', $debug['DB_HOST']['source']); + self::assertSame('String', $debug['DB_HOST']['type']); + self::assertSame('localhost', $debug['DB_HOST']['value']); + self::assertFalse($debug['DB_HOST']['overridden']); + } + + public function testDebugTracksOverrides(): void + { + $this->createEnvFile('.env', 'DB_HOST=base'); + $this->createEnvFile('.env.local', 'DB_HOST=override'); + + $config = new DotenvConfiguration(loadMode: LoadMode::Overwrite); + $dotenv = new Dotenv($this->fixturesDir, $config, '.env', '.env.local'); + $dotenv->load(); + + $debug = $dotenv->debug(); + + self::assertSame('.env.local', $debug['DB_HOST']['source']); + self::assertTrue($debug['DB_HOST']['overridden']); + self::assertSame('override', $debug['DB_HOST']['value']); + } + + // ── ${VAR:+alternate} Syntax ────────────────────────────────────── + + public function testAlternateSyntaxInIntegration(): void + { + $this->createEnvFile('.env', "REDIS_HOST=redis.local\nHAS_CACHE=\${REDIS_HOST:+yes}"); + + $dotenv = new Dotenv($this->fixturesDir); + $dotenv->load(); + + // TypeSystem casts 'yes' → bool true; verify raw value also carried correctly + $vars = $dotenv->variables(); + self::assertArrayHasKey('HAS_CACHE', $vars); + self::assertSame('yes', $vars['HAS_CACHE']->rawValue); + // The typed value 'yes' is cast to boolean true by TypeSystem + self::assertTrue($dotenv->get('HAS_CACHE')); + } + + // ── Helpers ─────────────────────────────────────────────────────── + + private function createEnvFile(string $filename, string $content): void + { + file_put_contents( + $this->fixturesDir . '/' . $filename, + ltrim($content), + ); + } + + private function removeDirectory(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($dir); } } diff --git a/tests/Integration/TypeSystemIntegrationTest.php b/tests/Integration/TypeSystemIntegrationTest.php deleted file mode 100644 index d5e25bf..0000000 --- a/tests/Integration/TypeSystemIntegrationTest.php +++ /dev/null @@ -1,115 +0,0 @@ -typeSystem = new DotenvTypeSystem(); - } - - public function testDefaultTypeDetectionAndCasting(): void - { - $testCases = [ - ['input' => 'string', 'expected' => 'string'], - ['input' => '42', 'expected' => 42], - ['input' => '3.14', 'expected' => 3.14], - ['input' => 'true', 'expected' => true], - ['input' => 'null', 'expected' => null], - ['input' => '[1,2,3]', 'expected' => [1, 2, 3]], - ['input' => '{"key":"value"}', 'expected' => ['key' => 'value']], - ]; - - foreach ($testCases as $case) { - $result = $this->typeSystem->processValue($case['input']); - $this->assertEquals($case['expected'], $result, "Failed processing {$case['input']}"); - } - } - - public function testCustomTypeDetectorAndCaster(): void - { - $customDetector = new class implements TypeDetector { - public function detect(mixed $value): ?string - { - return 'CUSTOM' === $value ? 'custom_type' : null; - } - - public function getPriority(): int - { - return 1000; - } - }; - - $customCaster = new class implements TypeCaster { - public function cast(mixed $value): string - { - return "Processed: $value"; - } - }; - - $this->typeSystem->registerDetector($customDetector); - $this->typeSystem->registerCaster('custom_type', $customCaster); - - $result = $this->typeSystem->processValue('CUSTOM'); - $this->assertSame('Processed: CUSTOM', $result); - } - - public function testDetectorPrioritization(): void - { - $lowPriorityDetector = new class implements TypeDetector { - public function detect(mixed $value): ?string - { - return 'low_priority'; - } - - public function getPriority(): int - { - return 10; - } - }; - - $highPriorityDetector = new class implements TypeDetector { - public function detect(mixed $value): ?string - { - return 'high_priority'; - } - - public function getPriority(): int - { - return 100; - } - }; - - $this->typeSystem->registerDetector($lowPriorityDetector); - $this->typeSystem->registerDetector($highPriorityDetector); - - $this->typeSystem->registerCaster('low_priority', new class implements TypeCaster { - public function cast(mixed $value): string - { - return "Low: $value"; - } - }); - - $this->typeSystem->registerCaster('high_priority', new class implements TypeCaster { - public function cast(mixed $value): string - { - return "High: $value"; - } - }); - - $result = $this->typeSystem->processValue('test'); - $this->assertSame('High: test', $result); - } -} diff --git a/tests/Unit/Cache/PhpFileCacheTest.php b/tests/Unit/Cache/PhpFileCacheTest.php new file mode 100644 index 0000000..f242a07 --- /dev/null +++ b/tests/Unit/Cache/PhpFileCacheTest.php @@ -0,0 +1,157 @@ +tempDir = sys_get_temp_dir() . '/kariricode_dotenv_test_' . uniqid(); + mkdir($this->tempDir, 0o777, true); + } + + protected function tearDown(): void + { + // glob('*') misses dotfiles (e.g. .env.cache.php) — use iterator instead. + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->tempDir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($this->tempDir); + } + + public function testDumpAndLoadRoundTrip(): void + { + $cache = new PhpFileCache(); + $path = $this->tempDir . '/.env.cache.php'; + $variables = [ + 'DB_HOST' => 'localhost', + 'DB_PORT' => '5432', + 'APP_DEBUG' => 'true', + ]; + + $cache->dump($path, $variables, 'abc123'); + + $loaded = $cache->load($path, 'abc123'); + + $this->assertSame($variables, $loaded); + } + + public function testLoadReturnsNullWhenFileMissing(): void + { + $cache = new PhpFileCache(); + $this->assertNull($cache->load($this->tempDir . '/nonexistent.php')); + } + + public function testLoadReturnsNullWhenHashMismatch(): void + { + $cache = new PhpFileCache(); + $path = $this->tempDir . '/.env.cache.php'; + + $cache->dump($path, ['KEY' => 'value'], 'hash_v1'); + + // Load with different expected hash + $this->assertNull($cache->load($path, 'hash_v2')); + } + + public function testLoadIgnoresHashWhenExpectedIsEmpty(): void + { + $cache = new PhpFileCache(); + $path = $this->tempDir . '/.env.cache.php'; + + $cache->dump($path, ['KEY' => 'value'], 'some_hash'); + + $loaded = $cache->load($path, ''); + $this->assertSame(['KEY' => 'value'], $loaded); + } + + public function testClearRemovesFile(): void + { + $cache = new PhpFileCache(); + $path = $this->tempDir . '/.env.cache.php'; + + $cache->dump($path, ['KEY' => 'value']); + $this->assertFileExists($path); + + $cache->clear($path); + $this->assertFileDoesNotExist($path); + } + + public function testClearSilentlyIgnoresMissingFile(): void + { + $cache = new PhpFileCache(); + $cache->clear($this->tempDir . '/nonexistent.php'); + $this->assertTrue(true); + } + + public function testComputeSourceHashDeterministic(): void + { + $envFile = $this->tempDir . '/.env'; + file_put_contents($envFile, "DB_HOST=localhost\n"); + + $cache = new PhpFileCache(); + + $hash1 = $cache->computeSourceHash([$envFile]); + $hash2 = $cache->computeSourceHash([$envFile]); + + $this->assertSame($hash1, $hash2); + } + + public function testComputeSourceHashChangesWhenFileChanges(): void + { + $envFile = $this->tempDir . '/.env'; + file_put_contents($envFile, "DB_HOST=localhost\n"); + + $cache = new PhpFileCache(); + $hash1 = $cache->computeSourceHash([$envFile]); + + // Touch to change mtime + sleep(1); + file_put_contents($envFile, "DB_HOST=production\n"); + clearstatcache(); + + $hash2 = $cache->computeSourceHash([$envFile]); + + $this->assertNotSame($hash1, $hash2); + } + + public function testDumpedFileIsValidPhp(): void + { + $cache = new PhpFileCache(); + $path = $this->tempDir . '/.env.cache.php'; + + $cache->dump($path, ['A' => 'B'], 'hash'); + + $content = file_get_contents($path); + $this->assertStringContainsString('assertStringContainsString('Auto-generated', $content); + $this->assertStringContainsString("'A' => 'B'", $content); + } + + public function testDumpHandlesSpecialCharacters(): void + { + $cache = new PhpFileCache(); + $path = $this->tempDir . '/.env.cache.php'; + + $variables = [ + 'DSN' => "pgsql:host=localhost;dbname=ar_online", + 'QUOTE' => "He said \"hello\"", + 'NEWLINE' => "line1\nline2", + ]; + + $cache->dump($path, $variables, 'hash'); + $loaded = $cache->load($path, 'hash'); + + $this->assertSame($variables, $loaded); + } +} diff --git a/tests/Unit/Core/DotenvParserTest.php b/tests/Unit/Core/DotenvParserTest.php new file mode 100644 index 0000000..0324915 --- /dev/null +++ b/tests/Unit/Core/DotenvParserTest.php @@ -0,0 +1,271 @@ +parser = new DotenvParser(); + } + + // ── Basic Parsing ───────────────────────────────────────────────── + + public function testParsesSimpleKeyValue(): void + { + $result = $this->parser->parse("FOO=bar\nBAZ=qux"); + + self::assertSame(['FOO' => 'bar', 'BAZ' => 'qux'], $result); + } + + public function testSkipsEmptyLines(): void + { + $result = $this->parser->parse("FOO=bar\n\n\nBAZ=qux\n"); + + self::assertSame(['FOO' => 'bar', 'BAZ' => 'qux'], $result); + } + + public function testSkipsCommentLines(): void + { + $result = $this->parser->parse("# This is a comment\nFOO=bar\n# Another comment\nBAZ=qux"); + + self::assertSame(['FOO' => 'bar', 'BAZ' => 'qux'], $result); + } + + public function testStripsExportPrefix(): void + { + $result = $this->parser->parse("export FOO=bar\nexport BAZ=qux"); + + self::assertSame(['FOO' => 'bar', 'BAZ' => 'qux'], $result); + } + + public function testEmptyValueForBareKey(): void + { + $result = $this->parser->parse('FOO='); + + self::assertSame(['FOO' => ''], $result); + } + + // ── Quoted Values ───────────────────────────────────────────────── + + public function testParsesDoubleQuotedValues(): void + { + $result = $this->parser->parse('FOO="hello world"'); + + self::assertSame(['FOO' => 'hello world'], $result); + } + + public function testParsesSingleQuotedValues(): void + { + $result = $this->parser->parse("FOO='hello world'"); + + self::assertSame(['FOO' => 'hello world'], $result); + } + + public function testSingleQuotesAreRawLiteral(): void + { + $result = $this->parser->parse("FOO='hello \$BAR \${BAZ}'"); + + self::assertSame(['FOO' => 'hello $BAR ${BAZ}'], $result); + } + + public function testDoubleQuotedEscapeSequences(): void + { + $result = $this->parser->parse('FOO="hello\nworld\ttab"'); + + self::assertSame(['FOO' => "hello\nworld\ttab"], $result); + } + + public function testDoubleQuotedEscapedQuote(): void + { + $result = $this->parser->parse('FOO="say \"hello\""'); + + self::assertSame(['FOO' => 'say "hello"'], $result); + } + + public function testUnterminatedDoubleQuoteThrows(): void + { + $this->expectException(ParseException::class); + $this->expectExceptionMessage('Unterminated'); + + $this->parser->parse('FOO="unterminated'); + } + + public function testUnterminatedSingleQuoteThrows(): void + { + $this->expectException(ParseException::class); + $this->expectExceptionMessage('Unterminated'); + + $this->parser->parse("FOO='unterminated"); + } + + // ── Inline Comments ─────────────────────────────────────────────── + + public function testStripsInlineComments(): void + { + $result = $this->parser->parse('FOO=bar # this is a comment'); + + self::assertSame(['FOO' => 'bar'], $result); + } + + public function testHashInsideQuotesIsNotComment(): void + { + $result = $this->parser->parse('FOO="bar # not a comment"'); + + self::assertSame(['FOO' => 'bar # not a comment'], $result); + } + + // ── Variable Interpolation ──────────────────────────────────────── + + public function testExpandsBraceVariables(): void + { + $result = $this->parser->parse("FOO=hello\nBAR=\${FOO} world"); + + self::assertSame(['FOO' => 'hello', 'BAR' => 'hello world'], $result); + } + + public function testExpandsBareVariables(): void + { + $result = $this->parser->parse("FOO=hello\nBAR=\$FOO world"); + + self::assertSame(['FOO' => 'hello', 'BAR' => 'hello world'], $result); + } + + public function testExpandsVariablesInDoubleQuotes(): void + { + $result = $this->parser->parse("APP=KaririCode\nGREET=\"Welcome to \${APP}\""); + + self::assertSame(['APP' => 'KaririCode', 'GREET' => 'Welcome to KaririCode'], $result); + } + + public function testDefaultValueSyntax(): void + { + $result = $this->parser->parse('FOO=${UNDEFINED:-fallback}'); + + self::assertSame(['FOO' => 'fallback'], $result); + } + + public function testDefaultValueIgnoredWhenDefined(): void + { + $result = $this->parser->parse("VAR=hello\nFOO=\${VAR:-fallback}"); + + self::assertSame(['VAR' => 'hello', 'FOO' => 'hello'], $result); + } + + public function testAlternateSyntaxUsesAlternateWhenDefined(): void + { + $result = $this->parser->parse("HOST=redis.local\nHAS_CACHE=\${HOST:+yes}"); + + self::assertSame([ + 'HOST' => 'redis.local', + 'HAS_CACHE' => 'yes', + ], $result); + } + + public function testAlternateSyntaxReturnsEmptyWhenUndefined(): void + { + $result = $this->parser->parse('CACHE=${UNDEFINED_HOST:+redis://fallback}'); + + self::assertSame(['CACHE' => ''], $result); + } + + public function testAlternateSyntaxReturnsEmptyWhenEmpty(): void + { + $result = $this->parser->parse("HOST=\nHAS_CACHE=\${HOST:+yes}"); + + self::assertSame(['HOST' => '', 'HAS_CACHE' => ''], $result); + } + + public function testUndefinedVariableResolvesToEmpty(): void + { + $result = $this->parser->parse('FOO=$NONEXISTENT'); + + self::assertSame(['FOO' => ''], $result); + } + + // ── Multiline Values ────────────────────────────────────────────── + + public function testMultilineDoubleQuotedValue(): void + { + $content = "FOO=\"line1\nline2\nline3\""; + $result = $this->parser->parse($content); + + self::assertSame(['FOO' => "line1\nline2\nline3"], $result); + } + + // ── Strict Name Validation ──────────────────────────────────────── + + public function testStrictNamesRejectsLowercase(): void + { + $this->expectException(ParseException::class); + $this->expectExceptionMessage('Invalid variable name'); + + $this->parser->parse('foo_bar=value', '.env', strictNames: true); + } + + public function testStrictNamesAcceptsUppercase(): void + { + $result = $this->parser->parse('FOO_BAR=value', '.env', strictNames: true); + + self::assertSame(['FOO_BAR' => 'value'], $result); + } + + public function testNonStrictAcceptsLowercase(): void + { + $result = $this->parser->parse('foo_bar=value', '.env', strictNames: false); + + self::assertSame(['foo_bar' => 'value'], $result); + } + + // ── Edge Cases ──────────────────────────────────────────────────── + + public function testEmptyContent(): void + { + $result = $this->parser->parse(''); + + self::assertSame([], $result); + } + + public function testCommentOnlyContent(): void + { + $result = $this->parser->parse("# Just comments\n# Nothing else"); + + self::assertSame([], $result); + } + + public function testValueWithEqualsSign(): void + { + $result = $this->parser->parse('CONNECTION=postgresql://user:pass@host/db?sslmode=require'); + + self::assertSame(['CONNECTION' => 'postgresql://user:pass@host/db?sslmode=require'], $result); + } + + public function testWindowsLineEndings(): void + { + $result = $this->parser->parse("FOO=bar\r\nBAZ=qux\r\n"); + + self::assertSame(['FOO' => 'bar', 'BAZ' => 'qux'], $result); + } + + public function testValueWithSpacesUnquoted(): void + { + $result = $this->parser->parse('FOO=hello world'); + + self::assertSame(['FOO' => 'hello world'], $result); + } + + public function testEscapedDollarInDoubleQuotes(): void + { + $result = $this->parser->parse('PRICE="\\$100"'); + + self::assertSame(['PRICE' => '$100'], $result); + } +} diff --git a/tests/Unit/DotenvFactoryTest.php b/tests/Unit/DotenvFactoryTest.php deleted file mode 100644 index cfbc685..0000000 --- a/tests/Unit/DotenvFactoryTest.php +++ /dev/null @@ -1,50 +0,0 @@ -assertInstanceOf(Dotenv::class, $dotenv); - - $reflection = new \ReflectionClass($dotenv); - - $loaderProp = $reflection->getProperty('loader'); - $loaderProp->setAccessible(true); - $loader = $loaderProp->getValue($dotenv); - - $parserProp = $reflection->getProperty('parser'); - $parserProp->setAccessible(true); - $parser = $parserProp->getValue($dotenv); - - $this->assertInstanceOf(FileLoader::class, $loader); - $this->assertInstanceOf(DefaultParser::class, $parser); - } - - public function testCreateWithStrictMode(): void - { - $path = '/path/to/.env'; - $dotenv = DotenvFactory::create($path, true); - - $reflection = new \ReflectionClass($dotenv); - - $parserProp = $reflection->getProperty('parser'); - $parserProp->setAccessible(true); - $parser = $parserProp->getValue($dotenv); - - $this->assertInstanceOf(StrictParser::class, $parser); - } -} diff --git a/tests/Unit/DotenvTest.php b/tests/Unit/DotenvTest.php deleted file mode 100644 index d731646..0000000 --- a/tests/Unit/DotenvTest.php +++ /dev/null @@ -1,78 +0,0 @@ -loader = $this->createMock(Loader::class); - $this->parser = $this->createMock(Parser::class); - $this->typeSystem = $this->createMock(TypeSystem::class); - $this->dotenv = new Dotenv($this->parser, $this->loader, $this->typeSystem); - } - - public function testLoad(): void - { - $this->loader->expects($this->once()) - ->method('load') - ->willReturn('KEY=value'); - - $this->parser->expects($this->once()) - ->method('parse') - ->with('KEY=value') - ->willReturn(['KEY' => 'value']); - - $this->typeSystem->expects($this->once()) - ->method('processValue') - ->with('value') - ->willReturn('processed_value'); - - $this->dotenv->load(); - - $this->assertEquals('processed_value', $_ENV['KEY']); - $this->assertEquals('processed_value', $_SERVER['KEY']); - } - - public function testAddTypeDetector(): void - { - $detector = $this->createMock(TypeDetector::class); - - $this->typeSystem->expects($this->once()) - ->method('registerDetector') - ->with($detector); - - $result = $this->dotenv->addTypeDetector($detector); - - $this->assertSame($this->dotenv, $result); - } - - public function testAddTypeCaster(): void - { - $caster = $this->createMock(TypeCaster::class); - - $this->typeSystem->expects($this->once()) - ->method('registerCaster') - ->with('test_type', $caster); - - $result = $this->dotenv->addTypeCaster('test_type', $caster); - - $this->assertSame($this->dotenv, $result); - } -} diff --git a/tests/Unit/Enum/EnumTest.php b/tests/Unit/Enum/EnumTest.php new file mode 100644 index 0000000..9313983 --- /dev/null +++ b/tests/Unit/Enum/EnumTest.php @@ -0,0 +1,71 @@ +name); + self::assertSame('Overwrite', LoadMode::Overwrite->name); + self::assertSame('SkipExisting', LoadMode::SkipExisting->name); + } + + public function testLoadModeHasThreeCases(): void + { + self::assertCount(3, LoadMode::cases()); + } + + public function testLoadModeIsBackedByNothing(): void + { + // Pure enum (no backing type) + $reflection = new \ReflectionEnum(LoadMode::class); + self::assertFalse($reflection->isBacked()); + } + + public function testLoadModeIdentity(): void + { + self::assertSame(LoadMode::Immutable, LoadMode::Immutable); + self::assertNotSame(LoadMode::Immutable, LoadMode::Overwrite); + self::assertNotSame(LoadMode::Immutable, LoadMode::SkipExisting); + } + + // ── ValueType ───────────────────────────────────────────────────── + + public function testValueTypeCasesExist(): void + { + self::assertSame('String', ValueType::String->name); + self::assertSame('Integer', ValueType::Integer->name); + self::assertSame('Float', ValueType::Float->name); + self::assertSame('Boolean', ValueType::Boolean->name); + self::assertSame('Null', ValueType::Null->name); + self::assertSame('Json', ValueType::Json->name); + self::assertSame('Array', ValueType::Array->name); + } + + public function testValueTypeHasSevenCases(): void + { + self::assertCount(7, ValueType::cases()); + } + + public function testValueTypeIsBackedByNothing(): void + { + $reflection = new \ReflectionEnum(ValueType::class); + self::assertFalse($reflection->isBacked()); + } + + public function testValueTypeIdentity(): void + { + self::assertSame(ValueType::String, ValueType::String); + self::assertNotSame(ValueType::String, ValueType::Integer); + self::assertNotSame(ValueType::Boolean, ValueType::Integer); + } +} diff --git a/tests/Unit/EnvFunctionTest.php b/tests/Unit/EnvFunctionTest.php deleted file mode 100644 index 933e93c..0000000 --- a/tests/Unit/EnvFunctionTest.php +++ /dev/null @@ -1,64 +0,0 @@ -assertEquals('env_value', env('TEST_VAR')); - } - - public function testEnvReturnsValueFromServer(): void - { - $_SERVER['TEST_VAR'] = 'server_value'; - $this->assertEquals('server_value', env('TEST_VAR')); - } - - public function testEnvReturnsValueFromGetenv(): void - { - putenv('TEST_VAR=getenv_value'); - $this->assertEquals('getenv_value', env('TEST_VAR')); - } - - public function testEnvPrioritizesEnvOverServerAndGetenv(): void - { - $_ENV['TEST_VAR'] = 'env_value'; - $_SERVER['TEST_VAR'] = 'server_value'; - putenv('TEST_VAR=getenv_value'); - $this->assertEquals('env_value', env('TEST_VAR')); - } - - public function testEnvReturnsDefaultValueWhenNotSet(): void - { - $this->assertEquals('default', env('NONEXISTENT_VAR', 'default')); - } - - public function testEnvReturnsNullForNonexistentVarWithoutDefault(): void - { - $this->assertNull(env('NONEXISTENT_VAR')); - } - - protected function tearDown(): void - { - parent::tearDown(); - $_ENV = []; - $_SERVER = []; - putenv('TEST_ENV_VAR='); - } -} diff --git a/tests/Unit/Exception/ExceptionTest.php b/tests/Unit/Exception/ExceptionTest.php new file mode 100644 index 0000000..55991b4 --- /dev/null +++ b/tests/Unit/Exception/ExceptionTest.php @@ -0,0 +1,152 @@ +getMessage()); + self::assertStringContainsString('not found', $e->getMessage()); + } + + public function testFileNotFoundExceptionIsFinal(): void + { + $reflection = new \ReflectionClass(FileNotFoundException::class); + self::assertTrue($reflection->isFinal()); + } + + // ── ImmutableException ──────────────────────────────────────────── + + public function testImmutableExceptionAlreadyDefined(): void + { + $e = ImmutableException::alreadyDefined('DB_HOST'); + + self::assertInstanceOf(DotenvException::class, $e); + self::assertStringContainsString('DB_HOST', $e->getMessage()); + self::assertStringContainsString('Immutable', $e->getMessage()); + } + + public function testImmutableExceptionIsFinal(): void + { + $reflection = new \ReflectionClass(ImmutableException::class); + self::assertTrue($reflection->isFinal()); + } + + // ── ParseException ──────────────────────────────────────────────── + + public function testParseExceptionInvalidLine(): void + { + $e = ParseException::invalidLine('malformed line', 42, '/app/.env'); + + self::assertInstanceOf(DotenvException::class, $e); + self::assertStringContainsString('42', $e->getMessage()); + self::assertStringContainsString('/app/.env', $e->getMessage()); + self::assertStringContainsString('malformed line', $e->getMessage()); + } + + public function testParseExceptionInvalidVariableNameStrict(): void + { + $e = ParseException::invalidVariableName('myVar', 5, '/app/.env', strict: true); + + self::assertStringContainsString('myVar', $e->getMessage()); + self::assertStringContainsString('uppercase', $e->getMessage()); + } + + public function testParseExceptionInvalidVariableNameNonStrict(): void + { + $e = ParseException::invalidVariableName('invalid!', 5, '/app/.env', strict: false); + + self::assertStringContainsString('invalid!', $e->getMessage()); + self::assertStringContainsString('letters', $e->getMessage()); + } + + public function testParseExceptionUnterminatedQuote(): void + { + $e = ParseException::unterminatedQuote(10, '/app/.env'); + + self::assertStringContainsString('10', $e->getMessage()); + self::assertStringContainsString('/app/.env', $e->getMessage()); + self::assertStringContainsString('Unterminated', $e->getMessage()); + } + + public function testParseExceptionCircularReference(): void + { + $e = ParseException::circularReference('SELF_REF', '/app/.env'); + + self::assertStringContainsString('SELF_REF', $e->getMessage()); + self::assertStringContainsString('Circular', $e->getMessage()); + } + + public function testParseExceptionIsFinal(): void + { + $reflection = new \ReflectionClass(ParseException::class); + self::assertTrue($reflection->isFinal()); + } + + // ── ValidationException ─────────────────────────────────────────── + + public function testValidationExceptionMissingRequired(): void + { + $e = ValidationException::missingRequired(['DB_HOST', 'DB_PORT']); + + self::assertInstanceOf(DotenvException::class, $e); + self::assertStringContainsString('DB_HOST', $e->getMessage()); + self::assertStringContainsString('DB_PORT', $e->getMessage()); + self::assertSame([], $e->errors()); // No batch errors + } + + public function testValidationExceptionBatchErrors(): void + { + $errors = ['DB_HOST is required', 'DB_PORT must be integer', 'APP_ENV invalid']; + $e = ValidationException::batchErrors($errors); + + self::assertSame($errors, $e->errors()); + self::assertCount(3, $e->errors()); + foreach ($errors as $error) { + self::assertStringContainsString($error, $e->getMessage()); + } + } + + public function testValidationExceptionSchemaViolation(): void + { + $e = ValidationException::schemaViolation('DB_PORT must be integer'); + + self::assertStringContainsString('Schema', $e->getMessage()); + self::assertStringContainsString('DB_PORT', $e->getMessage()); + } + + public function testValidationExceptionErrorsDefaultToEmpty(): void + { + $e = ValidationException::missingRequired(['X']); + self::assertSame([], $e->errors()); + } + + public function testValidationExceptionIsFinal(): void + { + $reflection = new \ReflectionClass(ValidationException::class); + self::assertTrue($reflection->isFinal()); + } +} diff --git a/tests/Unit/Loader/ArrayLoaderTest.php b/tests/Unit/Loader/ArrayLoaderTest.php deleted file mode 100644 index 86ec058..0000000 --- a/tests/Unit/Loader/ArrayLoaderTest.php +++ /dev/null @@ -1,33 +0,0 @@ - 'value1', - 'KEY2' => 'value2', - ]; - - $loader = new ArrayLoader($variables); - $result = $loader->load(); - - $expected = "KEY1=value1\nKEY2=value2\n"; - $this->assertEquals($expected, $result); - } - - public function testLoadWithEmptyArray(): void - { - $loader = new ArrayLoader([]); - $result = $loader->load(); - - $this->assertEquals('', $result); - } -} diff --git a/tests/Unit/Loader/FileLoaderTest.php b/tests/Unit/Loader/FileLoaderTest.php deleted file mode 100644 index e1fe866..0000000 --- a/tests/Unit/Loader/FileLoaderTest.php +++ /dev/null @@ -1,84 +0,0 @@ -tempDir = sys_get_temp_dir() . '/dotenv_test_' . uniqid(); - mkdir($this->tempDir); - } - - protected function tearDown(): void - { - parent::tearDown(); - $this->removeDirectory($this->tempDir); - } - - public function testLoad(): void - { - $filePath = $this->tempDir . '/test.env'; - file_put_contents($filePath, 'TEST_KEY=test_value'); - - $loader = new FileLoader($filePath); - $result = $loader->load(); - - $this->assertSame('TEST_KEY=test_value', $result); - } - - public function testLoadNonExistentFile(): void - { - $filePath = $this->tempDir . '/non_existent.env'; - - $loader = new FileLoader($filePath); - - $this->expectException(InvalidFileException::class); - $this->expectExceptionMessage('The environment file ' . $filePath . ' does not exist.'); - - $loader->load(); - } - - public function testLoadUnreadableFile(): void - { - $filePath = $this->tempDir . '/unreadable.env'; - file_put_contents($filePath, 'TEST_KEY=test_value'); - - $loader = $this->getMockBuilder(FileLoader::class) - ->setConstructorArgs([$filePath]) - ->onlyMethods(['getFileContents']) - ->getMock(); - - $loader->expects($this->once()) - ->method('getFileContents') - ->willReturn(false); - - $this->expectException(InvalidFileException::class); - $this->expectExceptionMessage('Unable to read the environment file at ' . $filePath); - - $loader->load(); - } - - private function removeDirectory(string $dir): void - { - if (!file_exists($dir)) { - return; - } - - $files = array_diff(scandir($dir), ['.', '..']); - foreach ($files as $file) { - $path = $dir . '/' . $file; - is_dir($path) ? $this->removeDirectory($path) : unlink($path); - } - rmdir($dir); - } -} diff --git a/tests/Unit/Parser/DefaultParserTest.php b/tests/Unit/Parser/DefaultParserTest.php deleted file mode 100644 index f036998..0000000 --- a/tests/Unit/Parser/DefaultParserTest.php +++ /dev/null @@ -1,98 +0,0 @@ -parser = new DefaultParser(); - } - - /** - * @dataProvider validInputProvider - */ - public function testParseWithValidInput(string $input, array $expected): void - { - $result = $this->parser->parse($input); - $this->assertEquals($expected, $result); - } - - public static function validInputProvider(): array - { - return [ - 'simple key-value' => [ - 'KEY=value', - ['KEY' => 'value'], - ], - 'multiple lines' => [ - "KEY1=value1\nKEY2=value2", - ['KEY1' => 'value1', 'KEY2' => 'value2'], - ], - 'with comments' => [ - "KEY=value\n# This is a comment\nANOTHER_KEY=another_value", - ['KEY' => 'value', 'ANOTHER_KEY' => 'another_value'], - ], - 'with empty lines' => [ - "KEY=value\n\nANOTHER_KEY=another_value", - ['KEY' => 'value', 'ANOTHER_KEY' => 'another_value'], - ], - 'with interpolation' => [ - "BASE_DIR=/var/www\nAPP_DIR=\${BASE_DIR}/app", - ['BASE_DIR' => '/var/www', 'APP_DIR' => '/var/www/app'], - ], - 'with quotes' => [ - 'QUOTED="This is a quoted value"', - ['QUOTED' => 'This is a quoted value'], - ], - ]; - } - - public function testParseWithInvalidName(): void - { - $input = "VALID_KEY=value\nINVALID-KEY=value"; - $result = $this->parser->parse($input); - $this->assertEquals(['VALID_KEY' => 'value', 'INVALID-KEY' => 'value'], $result); - } - - public function testParseWithEmptyName(): void - { - $input = '=value'; - $result = $this->parser->parse($input); - $this->assertEquals([], $result); - } - - public function testInterpolationWithUndefinedVariable(): void - { - $input = 'KEY=${UNDEFINED_VAR}'; - $result = $this->parser->parse($input); - $this->assertEquals(['KEY' => '${UNDEFINED_VAR}'], $result); - } - - public function testMultipleInterpolations(): void - { - $input = "BASE=/var\nAPP=/app\nPATH=\${BASE}\${APP}"; - $expected = ['BASE' => '/var', 'APP' => '/app', 'PATH' => '/var/app']; - $result = $this->parser->parse($input); - $this->assertEquals($expected, $result); - } - - public function testParsingEmptyContent(): void - { - $input = ''; - $this->assertEquals([], $this->parser->parse($input)); - } - - public function testParsingOnlyComments(): void - { - $input = "# This is a comment\n# Another comment"; - $this->assertEquals([], $this->parser->parse($input)); - } -} diff --git a/tests/Unit/Parser/StrictParserTest.php b/tests/Unit/Parser/StrictParserTest.php deleted file mode 100644 index 10d208b..0000000 --- a/tests/Unit/Parser/StrictParserTest.php +++ /dev/null @@ -1,134 +0,0 @@ -parser = new StrictParser(); - } - - /** - * @dataProvider validInputProvider - */ - public function testParseWithValidInput(string $input, array $expected): void - { - $result = $this->parser->parse($input); - $this->assertEquals($expected, $result); - } - - public static function validInputProvider(): array - { - return [ - 'simple key-value' => [ - 'KEY=value', - ['KEY' => 'value'], - ], - 'multiple lines' => [ - "KEY1=value1\nKEY2=value2", - ['KEY1' => 'value1', 'KEY2' => 'value2'], - ], - 'with comments' => [ - "KEY=value\n# This is a comment\nANOTHER_KEY=another_value", - ['KEY' => 'value', 'ANOTHER_KEY' => 'another_value'], - ], - 'with empty lines' => [ - "KEY=value\n\nANOTHER_KEY=another_value", - ['KEY' => 'value', 'ANOTHER_KEY' => 'another_value'], - ], - 'with interpolation' => [ - "BASE_DIR=/var/www\nAPP_DIR=\${BASE_DIR}/app", - ['BASE_DIR' => '/var/www', 'APP_DIR' => '/var/www/app'], - ], - 'with quotes' => [ - 'QUOTED="This is a quoted value"', - ['QUOTED' => 'This is a quoted value'], - ], - ]; - } - - public function testParseWithInvalidName(): void - { - $this->expectException(InvalidValueException::class); - $this->expectExceptionMessage('Invalid character in variable name'); - - $input = "VALID_KEY=value\nINVALID-KEY=value"; - $this->parser->parse($input); - } - - public function testParseWithEmptyName(): void - { - $this->expectException(InvalidValueException::class); - $this->expectExceptionMessage('Empty variable name'); - - $input = '=value'; - $this->parser->parse($input); - } - - public function testParseWithInvalidStartingCharacter(): void - { - $this->expectException(InvalidValueException::class); - $this->expectExceptionMessage('Variable name must start with a letter or underscore'); - - $input = '1INVALID=value'; - $this->parser->parse($input); - } - - /** - * @dataProvider invalidCharactersProvider - */ - public function testParseWithInvalidCharacters(string $input): void - { - $this->expectException(InvalidValueException::class); - $this->expectExceptionMessage('Invalid character in variable name'); - - $this->parser->parse($input); - } - - public static function invalidCharactersProvider(): array - { - return [ - 'with space' => ['INVALID KEY=value'], - 'with dash' => ['INVALID-KEY=value'], - 'with dot' => ['INVALID.KEY=value'], - 'with comma' => ['INVALID,KEY=value'], - 'with brackets' => ['INVALID[KEY]=value'], - ]; - } - - public function testInterpolationWithUndefinedVariable(): void - { - $input = 'KEY=${UNDEFINED_VAR}'; - $result = $this->parser->parse($input); - $this->assertEquals(['KEY' => '${UNDEFINED_VAR}'], $result); - } - - public function testMultipleInterpolations(): void - { - $input = "BASE=/var\nAPP=/app\nPATH=\${BASE}\${APP}"; - $expected = ['BASE' => '/var', 'APP' => '/app', 'PATH' => '/var/app']; - $result = $this->parser->parse($input); - $this->assertEquals($expected, $result); - } - - public function testParsingEmptyContent(): void - { - $input = ''; - $this->assertEquals([], $this->parser->parse($input)); - } - - public function testParsingOnlyComments(): void - { - $input = "# This is a comment\n# Another comment"; - $this->assertEquals([], $this->parser->parse($input)); - } -} diff --git a/tests/Unit/Parser/Trait/CommonParserFunctionalityTest.php b/tests/Unit/Parser/Trait/CommonParserFunctionalityTest.php deleted file mode 100644 index 49aac6d..0000000 --- a/tests/Unit/Parser/Trait/CommonParserFunctionalityTest.php +++ /dev/null @@ -1,146 +0,0 @@ -assertEquals($expected, $this->isValidSetter($line)); - } - - public static function validSetterProvider(): array - { - return [ - 'valid setter' => ['KEY=value', true], - 'setter with spaces' => [' KEY = value ', true], - 'comment' => ['# This is a comment', false], - 'empty line' => ['', false], - 'invalid line' => ['This is not a valid setter', false], - ]; - } - - /** - * @dataProvider commentProvider - */ - public function testIsComment(string $line, bool $expected): void - { - $this->assertEquals($expected, $this->isComment($line)); - } - - public static function commentProvider(): array - { - return [ - 'comment' => ['# This is a comment', true], - 'comment with leading space' => [' # This is a comment', true], - 'comment with multiple leading spaces' => [' # This is a comment', true], - 'not a comment' => ['This is not a comment', false], - 'empty line' => ['', false], - 'line with only spaces' => [' ', false], - ]; - } - - /** - * @dataProvider setterCharProvider - */ - public function testContainsSetterChar(string $line, bool $expected): void - { - $this->assertEquals($expected, $this->containsSetterChar($line)); - } - - public static function setterCharProvider(): array - { - return [ - 'contains setter char' => ['KEY=value', true], - 'multiple setter chars' => ['KEY=value=another', true], - 'no setter char' => ['This is a line without setter char', false], - 'empty line' => ['', false], - ]; - } - - /** - * @dataProvider parseEnvironmentVariableProvider - */ - public function testParseEnvironmentVariable(string $line, array $expected): void - { - $this->assertEquals($expected, $this->parseEnvironmentVariable($line)); - } - - public static function parseEnvironmentVariableProvider(): array - { - return [ - 'simple variable' => ['KEY=value', ['KEY', 'value']], - 'variable with spaces' => [' KEY = value ', ['KEY', 'value']], - 'variable with multiple equal signs' => ['KEY=value=another', ['KEY', 'value=another']], - 'empty value' => ['KEY=', ['KEY', '']], - 'invalid line' => ['This is not a valid variable', [null, null]], - ]; - } - - /** - * @dataProvider sanitizeValueProvider - */ - public function testSanitizeValue(string $value, string $expected): void - { - $this->assertEquals($expected, $this->sanitizeValue($value)); - } - - public static function sanitizeValueProvider(): array - { - return [ - 'unquoted value' => ['value', 'value'], - 'single quoted value' => ["'value'", 'value'], - 'double quoted value' => ['"value"', 'value'], - 'value with spaces' => [' value ', 'value'], - 'value with internal spaces' => [' value with spaces ', 'value with spaces'], - 'empty value' => ['', ''], - ]; - } - - /** - * @dataProvider removeQuotesProvider - */ - public function testRemoveQuotes(string $value, string $expected): void - { - $this->assertEquals($expected, $this->removeQuotes($value)); - } - - public static function removeQuotesProvider(): array - { - return [ - 'unquoted value' => ['value', 'value'], - 'single quoted value' => ["'value'", 'value'], - 'double quoted value' => ['"value"', 'value'], - 'mixed quotes' => ['"\'value\'"', "'value'"], - 'empty value' => ['', ''], - 'value with internal quotes' => ['"value \'quoted\' inside"', "value 'quoted' inside"], - ]; - } - - /** - * @dataProvider isValidKeyProvider - */ - public function testIsValidKey(?string $key, bool $expected): void - { - $this->assertEquals($expected, $this->isValidKey($key)); - } - - public static function isValidKeyProvider(): array - { - return [ - 'valid key' => ['KEY', true], - 'empty string' => ['', false], - 'null' => [null, false], - ]; - } -} diff --git a/tests/Unit/Processor/ProcessorTest.php b/tests/Unit/Processor/ProcessorTest.php new file mode 100644 index 0000000..cca4afe --- /dev/null +++ b/tests/Unit/Processor/ProcessorTest.php @@ -0,0 +1,109 @@ +process($raw, $raw), + ); + } + + public function testCsvToArrayReturnsEmptyForBlank(): void + { + $processor = new CsvToArrayProcessor(); + + self::assertSame([], $processor->process('', '')); + self::assertSame([], $processor->process(' ', ' ')); + } + + public function testCsvToArrayWithCustomSeparator(): void + { + $processor = new CsvToArrayProcessor(separator: '|'); + $raw = 'a|b|c'; + + self::assertSame( + ['a', 'b', 'c'], + $processor->process($raw, $raw), + ); + } + + // ── Base64DecodeProcessor ───────────────────────────────────────── + + public function testBase64DecodeProcessesValidInput(): void + { + $processor = new Base64DecodeProcessor(); + $encoded = base64_encode('secret-key'); + + self::assertSame('secret-key', $processor->process($encoded, $encoded)); + } + + public function testBase64DecodeThrowsOnInvalidInput(): void + { + $processor = new Base64DecodeProcessor(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid base64'); + + $processor->process('!!!invalid!!!', '!!!invalid!!!'); + } + + // ── TrimProcessor ───────────────────────────────────────────────── + + public function testTrimRemovesWhitespace(): void + { + $processor = new TrimProcessor(); + $raw = " hello\t\n"; + + self::assertSame('hello', $processor->process($raw, $raw)); + } + + public function testTrimWithCustomCharacters(): void + { + $processor = new TrimProcessor(characters: '/'); + + self::assertSame('path', $processor->process('/path/', '/path/')); + } + + // ── UrlNormalizerProcessor ──────────────────────────────────────── + + public function testUrlNormalizerAddsTrailingSlash(): void + { + $processor = new UrlNormalizerProcessor(); + $raw = 'https://api.example.com'; + + self::assertSame('https://api.example.com/', $processor->process($raw, $raw)); + } + + public function testUrlNormalizerPreservesExistingSlash(): void + { + $processor = new UrlNormalizerProcessor(); + $raw = 'https://api.example.com/'; + + self::assertSame('https://api.example.com/', $processor->process($raw, $raw)); + } + + public function testUrlNormalizerRemovesDoubleSlash(): void + { + $processor = new UrlNormalizerProcessor(); + $raw = 'https://api.example.com//'; + + self::assertSame('https://api.example.com/', $processor->process($raw, $raw)); + } +} diff --git a/tests/Unit/Schema/SchemaParserTest.php b/tests/Unit/Schema/SchemaParserTest.php new file mode 100644 index 0000000..3966a36 --- /dev/null +++ b/tests/Unit/Schema/SchemaParserTest.php @@ -0,0 +1,216 @@ +parser = new SchemaParser(); + } + + // ── Parsing ─────────────────────────────────────────────────────── + + public function testParsesBasicSchema(): void + { + $content = <<<'SCHEMA' + [DB_HOST] + required = true + type = string + notEmpty = true + + [DB_PORT] + required = true + type = integer + min = 1 + max = 65535 + SCHEMA; + + $schema = $this->parser->parse($content); + + self::assertArrayHasKey('DB_HOST', $schema); + self::assertArrayHasKey('DB_PORT', $schema); + self::assertSame('true', $schema['DB_HOST']['required']); + self::assertSame('string', $schema['DB_HOST']['type']); + self::assertSame('true', $schema['DB_HOST']['notEmpty']); + self::assertSame('integer', $schema['DB_PORT']['type']); + self::assertSame('1', $schema['DB_PORT']['min']); + self::assertSame('65535', $schema['DB_PORT']['max']); + } + + public function testSkipsCommentsAndEmptyLines(): void + { + $content = <<<'SCHEMA' + # This is a header comment + ; INI-style comment + + [APP_ENV] + required = true + allowed = local, staging, production + SCHEMA; + + $schema = $this->parser->parse($content); + + self::assertCount(1, $schema); + self::assertArrayHasKey('APP_ENV', $schema); + } + + public function testParsesAllowedValues(): void + { + $content = <<<'SCHEMA' + [APP_ENV] + allowed = local, staging, production + SCHEMA; + + $schema = $this->parser->parse($content); + + self::assertSame('local, staging, production', $schema['APP_ENV']['allowed']); + } + + public function testParsesRegexDirective(): void + { + $content = <<<'SCHEMA' + [API_KEY] + regex = /^[a-f0-9]{32}$/ + SCHEMA; + + $schema = $this->parser->parse($content); + + self::assertSame('/^[a-f0-9]{32}$/', $schema['API_KEY']['regex']); + } + + // ── Validator Application ───────────────────────────────────────── + + public function testApplyToValidatorPassesWithValidData(): void + { + $schema = $this->parser->parse(<<<'SCHEMA' + [DB_HOST] + required = true + notEmpty = true + + [DB_PORT] + required = true + type = integer + min = 1 + max = 65535 + SCHEMA); + + $variables = ['DB_HOST' => 'localhost', 'DB_PORT' => '5432']; + + $validator = new EnvironmentValidator( + fn (string $name): ?string => $variables[$name] ?? null, + ); + + $this->parser->applyToValidator($schema, $validator); + $validator->assert(); + + $this->assertTrue(true); + } + + public function testApplyToValidatorFailsOnMissingRequired(): void + { + $schema = $this->parser->parse(<<<'SCHEMA' + [DB_HOST] + required = true + SCHEMA); + + $validator = new EnvironmentValidator( + fn (string $name): ?string => null, + ); + + $this->parser->applyToValidator($schema, $validator); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('DB_HOST'); + + $validator->assert(); + } + + public function testApplyToValidatorFailsOnInvalidType(): void + { + $schema = $this->parser->parse(<<<'SCHEMA' + [DB_PORT] + required = true + type = integer + SCHEMA); + + $variables = ['DB_PORT' => 'not-a-number']; + + $validator = new EnvironmentValidator( + fn (string $name): ?string => $variables[$name] ?? null, + ); + + $this->parser->applyToValidator($schema, $validator); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('must be an integer'); + + $validator->assert(); + } + + public function testApplyToValidatorWithAllowedValues(): void + { + $schema = $this->parser->parse(<<<'SCHEMA' + [APP_ENV] + required = true + allowed = local, staging, production + SCHEMA); + + $variables = ['APP_ENV' => 'invalid']; + + $validator = new EnvironmentValidator( + fn (string $name): ?string => $variables[$name] ?? null, + ); + + $this->parser->applyToValidator($schema, $validator); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('must be one of'); + + $validator->assert(); + } + + public function testApplyToValidatorSkipsOptionalMissing(): void + { + $schema = $this->parser->parse(<<<'SCHEMA' + [OPTIONAL_VAR] + required = false + type = integer + SCHEMA); + + $validator = new EnvironmentValidator( + fn (string $name): ?string => null, + ); + + $this->parser->applyToValidator($schema, $validator); + $validator->assert(); + + $this->assertTrue(true); + } + + public function testUnknownTypeThrows(): void + { + $schema = $this->parser->parse(<<<'SCHEMA' + [VAR] + type = uuid + SCHEMA); + + $validator = new EnvironmentValidator( + fn (string $name): ?string => 'test', + ); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage("Unknown type 'uuid'"); + + $this->parser->applyToValidator($schema, $validator); + } +} diff --git a/tests/Unit/Security/EncryptorTest.php b/tests/Unit/Security/EncryptorTest.php new file mode 100644 index 0000000..982a417 --- /dev/null +++ b/tests/Unit/Security/EncryptorTest.php @@ -0,0 +1,134 @@ +hexKey = bin2hex(random_bytes(32)); + } + + public function testEncryptDecryptRoundTrip(): void + { + $encryptor = new Encryptor($this->hexKey); + $plaintext = 'super-secret-database-password'; + + $encrypted = $encryptor->encrypt($plaintext); + $decrypted = $encryptor->decrypt($encrypted); + + $this->assertSame($plaintext, $decrypted); + } + + public function testEncryptedValueHasPrefix(): void + { + $encryptor = new Encryptor($this->hexKey); + $encrypted = $encryptor->encrypt('test'); + + $this->assertTrue(Encryptor::isEncrypted($encrypted)); + $this->assertStringStartsWith('encrypted:', $encrypted); + } + + public function testDecryptReturnsPlaintextIfNotEncrypted(): void + { + $encryptor = new Encryptor($this->hexKey); + $plaintext = 'just-a-regular-value'; + + $this->assertSame($plaintext, $encryptor->decrypt($plaintext)); + } + + public function testIsEncryptedDetectsPrefix(): void + { + $this->assertTrue(Encryptor::isEncrypted('encrypted:abc123')); + $this->assertFalse(Encryptor::isEncrypted('plain-value')); + $this->assertFalse(Encryptor::isEncrypted('')); + } + + public function testDifferentNoncesProduceDifferentCiphertexts(): void + { + $encryptor = new Encryptor($this->hexKey); + $plaintext = 'same-input'; + + $enc1 = $encryptor->encrypt($plaintext); + $enc2 = $encryptor->encrypt($plaintext); + + $this->assertNotSame($enc1, $enc2); + + // Both decrypt to the same plaintext + $this->assertSame($plaintext, $encryptor->decrypt($enc1)); + $this->assertSame($plaintext, $encryptor->decrypt($enc2)); + } + + public function testWrongKeyFailsDecryption(): void + { + $encryptor1 = new Encryptor($this->hexKey); + $encrypted = $encryptor1->encrypt('secret'); + + $wrongKey = bin2hex(random_bytes(32)); + $encryptor2 = new Encryptor($wrongKey); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Decryption failed'); + + $encryptor2->decrypt($encrypted); + } + + public function testCorruptedPayloadThrows(): void + { + $encryptor = new Encryptor($this->hexKey); + + $this->expectException(\RuntimeException::class); + + $encryptor->decrypt('encrypted:' . base64_encode('short')); + } + + public function testInvalidKeyLengthThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('32 bytes'); + + new Encryptor('too-short'); + } + + public function testAcceptsRawBinaryKey(): void + { + $rawKey = random_bytes(32); + $encryptor = new Encryptor($rawKey); + + $encrypted = $encryptor->encrypt('binary-key-test'); + $this->assertSame('binary-key-test', $encryptor->decrypt($encrypted)); + } + + public function testEncryptsEmptyString(): void + { + $encryptor = new Encryptor($this->hexKey); + + $encrypted = $encryptor->encrypt(''); + $this->assertSame('', $encryptor->decrypt($encrypted)); + } + + public function testEncryptsUnicode(): void + { + $encryptor = new Encryptor($this->hexKey); + $plaintext = 'Régua de cobrança — ICP-Brasil 🇧🇷'; + + $encrypted = $encryptor->encrypt($plaintext); + $this->assertSame($plaintext, $encryptor->decrypt($encrypted)); + } + + public function testEncryptsLargePayload(): void + { + $encryptor = new Encryptor($this->hexKey); + $plaintext = str_repeat('A', 10_000); + + $encrypted = $encryptor->encrypt($plaintext); + $this->assertSame($plaintext, $encryptor->decrypt($encrypted)); + } +} diff --git a/tests/Unit/Security/KeyPairTest.php b/tests/Unit/Security/KeyPairTest.php new file mode 100644 index 0000000..721e31a --- /dev/null +++ b/tests/Unit/Security/KeyPairTest.php @@ -0,0 +1,60 @@ +assertSame(64, \strlen($kp->privateKey)); + $this->assertTrue(ctype_xdigit($kp->privateKey)); + $this->assertSame(8, \strlen($kp->publicId)); + } + + public function testGenerateProducesUniqueKeys(): void + { + $kp1 = KeyPair::generate(); + $kp2 = KeyPair::generate(); + + $this->assertNotSame($kp1->privateKey, $kp2->privateKey); + } + + public function testFromPrivateKeyReconstitutes(): void + { + $original = KeyPair::generate(); + $restored = KeyPair::fromPrivateKey($original->privateKey); + + $this->assertSame($original->privateKey, $restored->privateKey); + $this->assertSame($original->publicId, $restored->publicId); + } + + public function testFromPrivateKeyRejectsInvalidLength(): void + { + $this->expectException(\InvalidArgumentException::class); + + KeyPair::fromPrivateKey('abcdef'); + } + + public function testFromPrivateKeyRejectsNonHex(): void + { + $this->expectException(\InvalidArgumentException::class); + + KeyPair::fromPrivateKey(str_repeat('g', 64)); + } + + public function testGeneratedKeyWorksWithEncryptor(): void + { + $kp = KeyPair::generate(); + $encryptor = new \KaririCode\Dotenv\Security\Encryptor($kp->privateKey); + + $encrypted = $encryptor->encrypt('test-value'); + $this->assertSame('test-value', $encryptor->decrypt($encrypted)); + } +} diff --git a/tests/Unit/Type/Caster/ArrayCasterTest.php b/tests/Unit/Type/Caster/ArrayCasterTest.php deleted file mode 100644 index 39d6b1c..0000000 --- a/tests/Unit/Type/Caster/ArrayCasterTest.php +++ /dev/null @@ -1,54 +0,0 @@ -arrayCaster = new ArrayCaster(); - } - - /** - * @dataProvider validArrayStringProvider - */ - public function testCastValidArrayString(string $input, array $expected): void - { - $result = $this->arrayCaster->cast($input); - $this->assertSame($expected, $result); - } - - public static function validArrayStringProvider(): array - { - return [ - 'simple array' => ['[1, 2, 3]', ['1', '2', '3']], - 'array with spaces' => ['[ 1, 2, 3 ]', ['1', '2', '3']], - 'array with strings' => ['["a", "b", "c"]', ['a', 'b', 'c']], - 'array with mixed types' => ['[1, "two", 3.0]', ['1', 'two', '3.0']], - 'empty array' => ['[]', []], - 'array with single item' => ['[42]', ['42']], - ]; - } - - public function testCastNonStringValue(): void - { - $input = ['already', 'an', 'array']; - $result = $this->arrayCaster->cast($input); - $this->assertSame($input, $result); - } - - public function testCastNonArrayString(): void - { - $input = 'not an array'; - $result = $this->arrayCaster->cast($input); - $this->assertSame(['not an array'], $result); - } -} diff --git a/tests/Unit/Type/Caster/BooleanCasterTest.php b/tests/Unit/Type/Caster/BooleanCasterTest.php deleted file mode 100644 index 9dcbad8..0000000 --- a/tests/Unit/Type/Caster/BooleanCasterTest.php +++ /dev/null @@ -1,83 +0,0 @@ -booleanCaster = new BooleanCaster(); - } - - /** - * @dataProvider booleanValuesProvider - */ - public function testCast(mixed $input, bool $expected): void - { - $result = $this->booleanCaster->cast($input); - $this->assertSame($expected, $result); - } - - public static function booleanValuesProvider(): array - { - return [ - 'true string' => ['true', true], - 'false string' => ['false', false], - '1 string' => ['1', true], - '0 string' => ['0', false], - 'yes string' => ['yes', true], - 'no string' => ['no', false], - 'on string' => ['on', true], - 'off string' => ['off', false], - 'TRUE uppercase' => ['TRUE', true], - 'FALSE uppercase' => ['FALSE', false], - 'Yes uppercase' => ['Yes', true], - 'No uppercase' => ['No', false], - 'ON uppercase' => ['ON', true], - 'OFF uppercase' => ['OFF', false], - ]; - } - - public function testCastNonBooleanValue(): void - { - $input = 'not a boolean'; - $result = $this->booleanCaster->cast($input); - $this->assertFalse($result); - } - - /** - * @dataProvider canCastProvider - */ - public function testCanCast(mixed $input, bool $expected): void - { - $result = $this->booleanCaster->canCast($input); - $this->assertSame($expected, $result); - } - - public static function canCastProvider(): array - { - return [ - 'true string' => ['true', true], - 'false string' => ['false', true], - '1 string' => ['1', true], - '0 string' => ['0', true], - 'yes string' => ['yes', true], - 'no string' => ['no', true], - 'on string' => ['on', true], - 'off string' => ['off', true], - 'TRUE uppercase' => ['TRUE', true], - 'FALSE uppercase' => ['FALSE', true], - 'non-boolean string' => ['not a boolean', false], - 'integer' => ['42', false], - 'array' => ['[]', false], - ]; - } -} diff --git a/tests/Unit/Type/Caster/DotenvTypeCasterRegistryTest.php b/tests/Unit/Type/Caster/DotenvTypeCasterRegistryTest.php deleted file mode 100644 index 967b1e0..0000000 --- a/tests/Unit/Type/Caster/DotenvTypeCasterRegistryTest.php +++ /dev/null @@ -1,131 +0,0 @@ -registry = new DotenvTypeCasterRegistry(); - } - - public function testRegisterAndCast(): void - { - $mockCaster = $this->createMock(TypeCaster::class); - $mockCaster->expects($this->once()) - ->method('cast') - ->with('input') - ->willReturn('casted'); - - $this->registry->register('test_type', $mockCaster); - - $result = $this->registry->cast('test_type', 'input'); - $this->assertSame('casted', $result); - } - - public function testCastWithUnregisteredType(): void - { - $this->expectException(\OutOfRangeException::class); - $this->expectExceptionMessage('Key not found: unregistered_type'); - - $this->registry->cast('unregistered_type', 'input'); - } - - public function testCastWithVariousTypes(): void - { - $testCases = [ - 'registered_type' => ['string', 42, '42'], - 'null_type' => ['null', 'null', null], - ]; - - foreach ($testCases as $type => [$castType, $input, $expected]) { - $result = $this->registry->cast($castType, $input); - $this->assertEquals($expected, $result, "Casting '{$input}' as '{$castType}' should return '{$expected}'"); - } - - // Test for unregistered type - $this->expectException(\OutOfRangeException::class); - $this->registry->cast('unregistered', 'value'); - } - - public function testDefaultCasters(): void - { - $testCases = [ - 'array' => ['[1,2,3]', [1, 2, 3]], - 'json' => ['{"key":"value"}', ['key' => 'value']], - 'null' => ['null', null], - 'boolean' => ['true', true], - 'integer' => ['42', 42], - 'float' => ['3.14', 3.14], - 'string' => [42, '42'], - ]; - - foreach ($testCases as $type => [$input, $expected]) { - $result = $this->registry->cast($type, $input); - $this->assertEquals($expected, $result, "Default caster for '{$type}' should modify the input correctly"); - } - } - - public function testOverrideDefaultCaster(): void - { - $mockCaster = $this->createMock(TypeCaster::class); - $mockCaster->expects($this->once()) - ->method('cast') - ->with('input') - ->willReturn('custom_casted'); - - $this->registry->register('string', $mockCaster); - - $result = $this->registry->cast('string', 'input'); - $this->assertSame('custom_casted', $result); - } - - public function testFallbackWhenGetReturnsNonTypeCaster(): void - { - $mockArrayList = $this->createMock(ArrayList::class); - $mockArrayList->method('get') - ->willReturn(new \stdClass()); // Retorna um objeto que não é TypeCaster - - $reflectionProperty = new \ReflectionProperty(DotenvTypeCasterRegistry::class, 'casters'); - $reflectionProperty->setAccessible(true); - $reflectionProperty->setValue($this->registry, $mockArrayList); - - $input = 'test_value'; - $result = $this->registry->cast('any_type', $input); - - $this->assertSame($input, $result, 'Should return original value when get() returns non-TypeCaster'); - } - - public function testFallbackWhenGetReturnsNull(): void - { - $mockArrayList = $this->createMock(ArrayList::class); - $mockArrayList->method('get') - ->willReturn(null); - - $reflectionProperty = new \ReflectionProperty(DotenvTypeCasterRegistry::class, 'casters'); - $reflectionProperty->setAccessible(true); - $reflectionProperty->setValue($this->registry, $mockArrayList); - - $input = 'test_value'; - $result = $this->registry->cast('any_type', $input); - - $this->assertSame($input, $result, 'Should return original value when get() returns null'); - } - - public function testRegisterNonTypeCompliantCaster(): void - { - $this->expectException(\TypeError::class); - $this->registry->register('non_compliant', new \stdClass()); - } -} diff --git a/tests/Unit/Type/Caster/FloatCasterTest.php b/tests/Unit/Type/Caster/FloatCasterTest.php deleted file mode 100644 index 6253049..0000000 --- a/tests/Unit/Type/Caster/FloatCasterTest.php +++ /dev/null @@ -1,71 +0,0 @@ -floatCaster = new FloatCaster(); - } - - /** - * @dataProvider floatValuesProvider - */ - public function testCast(mixed $input, float $expected): void - { - $result = $this->floatCaster->cast($input); - $this->assertSame($expected, $result); - } - - public static function floatValuesProvider(): array - { - return [ - 'positive float string' => ['3.14', 3.14], - 'negative float string' => ['-2.5', -2.5], - 'zero float string' => ['0.0', 0.0], - 'float with leading zero' => ['0.5', 0.5], - 'float with trailing zero' => ['1.0', 1.0], - 'scientific notation' => ['1.23e-4', 1.23e-4], - 'float value' => [3.14, 3.14], - 'integer value' => [42, 42.0], - ]; - } - - public function testCastNonNumericValue(): void - { - $input = 'not a float'; - $result = $this->floatCaster->cast($input); - $this->assertSame(0.0, $result); - } - - /** - * @dataProvider canCastProvider - */ - public function testCanCast(mixed $input, bool $expected): void - { - $result = $this->floatCaster->canCast($input); - $this->assertSame($expected, $result); - } - - public static function canCastProvider(): array - { - return [ - 'float string' => ['3.14', true], - 'integer string' => ['42', false], - 'non-numeric string' => ['not a float', false], - 'float value' => [3.14, true], - 'integer value' => [42, false], - 'boolean value' => [true, false], - 'array' => [[], false], - ]; - } -} diff --git a/tests/Unit/Type/Caster/IntegerCasterTest.php b/tests/Unit/Type/Caster/IntegerCasterTest.php deleted file mode 100644 index 751738a..0000000 --- a/tests/Unit/Type/Caster/IntegerCasterTest.php +++ /dev/null @@ -1,69 +0,0 @@ -integerCaster = new IntegerCaster(); - } - - /** - * @dataProvider integerValuesProvider - */ - public function testCast(mixed $input, int $expected): void - { - $result = $this->integerCaster->cast($input); - $this->assertSame($expected, $result); - } - - public static function integerValuesProvider(): array - { - return [ - 'positive integer string' => ['42', 42], - 'negative integer string' => ['-10', -10], - 'zero string' => ['0', 0], - 'integer with leading zeros' => ['007', 7], - 'integer value' => [42, 42], - 'float value' => [3.14, 3], - ]; - } - - public function testCastNonNumericValue(): void - { - $input = 'not an integer'; - $result = $this->integerCaster->cast($input); - $this->assertSame(0, $result); - } - - /** - * @dataProvider canCastProvider - */ - public function testCanCast(mixed $input, bool $expected): void - { - $result = $this->integerCaster->canCast($input); - $this->assertSame($expected, $result); - } - - public static function canCastProvider(): array - { - return [ - 'integer string' => ['42', true], - 'float string' => ['3.14', false], - 'non-numeric string' => ['not an integer', false], - 'integer value' => [42, true], - 'float value' => [3.14, false], - 'boolean value' => [true, false], - 'array' => [[], false], - ]; - } -} diff --git a/tests/Unit/Type/Caster/JsonCasterTest.php b/tests/Unit/Type/Caster/JsonCasterTest.php deleted file mode 100644 index 63aec72..0000000 --- a/tests/Unit/Type/Caster/JsonCasterTest.php +++ /dev/null @@ -1,64 +0,0 @@ -jsonCaster = new JsonCaster(); - } - - /** - * @dataProvider jsonValuesProvider - */ - public function testCast(string $input, mixed $expected): void - { - $result = $this->jsonCaster->cast($input); - $this->assertEquals($expected, $result); - } - - public static function jsonValuesProvider(): array - { - return [ - 'simple object' => ['{"key":"value"}', ['key' => 'value']], - 'nested object' => ['{"outer":{"inner":"value"}}', ['outer' => ['inner' => 'value']]], - 'array' => ['[1,2,3]', [1, 2, 3]], - 'array of objects' => ['[{"id":1},{"id":2}]', [['id' => 1], ['id' => 2]]], - 'string' => ['"hello"', 'hello'], - 'number' => ['42', 42], - 'boolean' => ['true', true], - 'null' => ['null', null], - ]; - } - - public function testCastInvalidJson(): void - { - $input = 'not a json'; - $result = $this->jsonCaster->cast($input); - $this->assertSame($input, $result); - } - - public function testCastNonStringValue(): void - { - $input = ['already', 'an', 'array']; - $result = $this->jsonCaster->cast($input); - $this->assertSame($input, $result); - } - - public function testCastWithSurroundingQuotes(): void - { - $input = '"{"key":"value"}"'; - $expected = ['key' => 'value']; - $result = $this->jsonCaster->cast($input); - $this->assertEquals($expected, $result); - } -} diff --git a/tests/Unit/Type/Caster/NullCasterTest.php b/tests/Unit/Type/Caster/NullCasterTest.php deleted file mode 100644 index 634a193..0000000 --- a/tests/Unit/Type/Caster/NullCasterTest.php +++ /dev/null @@ -1,67 +0,0 @@ -nullCaster = new NullCaster(); - } - - /** - * @dataProvider nullValuesProvider - */ - public function testCast(mixed $input): void - { - $result = $this->nullCaster->cast($input); - $this->assertNull($result); - } - - public static function nullValuesProvider(): array - { - return [ - 'null string' => ['null'], - 'empty string' => [''], - 'NULL uppercase' => ['NULL'], - 'null value' => [null], - ]; - } - - /** - * @dataProvider canCastProvider - */ - public function testCanCast(mixed $input, bool $expected): void - { - $result = $this->nullCaster->canCast($input); - $this->assertSame($expected, $result); - } - - public static function canCastProvider(): array - { - return [ - 'null string' => ['null', true], - 'empty string' => ['', true], - 'NULL uppercase' => ['NULL', false], - 'non-null string' => ['not null', false], - 'integer' => [0, false], - 'boolean' => [false, false], - 'array' => [[], false], - ]; - } - - public function testCastNonNullValue(): void - { - $input = 'not null'; - $result = $this->nullCaster->cast($input); - $this->assertNull($result); - } -} diff --git a/tests/Unit/Type/Caster/StringCasterTest.php b/tests/Unit/Type/Caster/StringCasterTest.php deleted file mode 100644 index 0a533a4..0000000 --- a/tests/Unit/Type/Caster/StringCasterTest.php +++ /dev/null @@ -1,79 +0,0 @@ -stringCaster = new StringCaster(); - } - - /** - * @dataProvider stringValuesProvider - */ - public function testCast(mixed $input, string $expected): void - { - $result = $this->stringCaster->cast($input); - $this->assertSame($expected, $result); - } - - public static function stringValuesProvider(): array - { - return [ - 'simple string' => ['hello', 'hello'], - 'integer' => [42, '42'], - 'float' => [3.14, '3.14'], - 'boolean true' => [true, '1'], - 'boolean false' => [false, ''], - 'null' => [null, ''], - 'object with __toString' => [new class { - public function __toString() - { - return 'object string'; - } - }, 'object string'], - ]; - } - - public function testCastObjectWithoutToString(): void - { - $this->expectException(\Error::class); - $this->stringCaster->cast(new \stdClass()); - } - - /** - * @dataProvider canCastProvider - */ - public function testCanCast(mixed $input, bool $expected): void - { - $result = $this->stringCaster->canCast($input); - $this->assertSame($expected, $result); - } - - public static function canCastProvider(): array - { - return [ - 'string' => ['hello', true], - 'integer' => [42, true], - 'float' => [3.14, true], - 'boolean' => [true, true], - 'null' => [null, true], - 'object with __toString' => [new class { - public function __toString() - { - return 'object string'; - } - }, true], - 'object without __toString' => [new \stdClass(), false], - ]; - } -} diff --git a/tests/Unit/Type/Detector/ArrayDetectorTest.php b/tests/Unit/Type/Detector/ArrayDetectorTest.php deleted file mode 100644 index fb3ff2e..0000000 --- a/tests/Unit/Type/Detector/ArrayDetectorTest.php +++ /dev/null @@ -1,65 +0,0 @@ -detector = new ArrayDetector(); - } - - /** - * @dataProvider validArrayProvider - */ - public function testDetectValidArray(string $input): void - { - $this->assertSame('array', $this->detector->detect($input)); - } - - public static function validArrayProvider(): array - { - return [ - 'simple array' => ['[1, 2, 3]'], - 'nested array' => ['[1, [2, 3], 4]'], - 'array with strings' => ['["a", "b", "c"]'], - 'array with mixed types' => ['[1, "two", 3.0, true]'], - 'array with spaces' => ['[ 1, 2, 3 ]'], - 'empty array' => ['[]'], - ]; - } - - /** - * @dataProvider invalidArrayProvider - */ - public function testDetectInvalidArray($input): void - { - $this->assertNull($this->detector->detect($input)); - } - - public static function invalidArrayProvider(): array - { - return [ - 'string' => ['not an array'], - 'number' => [42], - 'boolean' => [true], - 'null' => [null], - 'object notation' => ['{"key": "value"}'], - 'unclosed bracket' => ['[1, 2, 3'], - 'no brackets' => ['1, 2, 3'], - ]; - } - - public function testGetPriority(): void - { - $this->assertSame(100, $this->detector->getPriority()); - } -} diff --git a/tests/Unit/Type/Detector/BooleanDetectorTest.php b/tests/Unit/Type/Detector/BooleanDetectorTest.php deleted file mode 100644 index dd2784e..0000000 --- a/tests/Unit/Type/Detector/BooleanDetectorTest.php +++ /dev/null @@ -1,69 +0,0 @@ -detector = new BooleanDetector(); - } - - /** - * @dataProvider validBooleanProvider - */ - public function testDetectValidBoolean($input): void - { - $this->assertSame('boolean', $this->detector->detect($input)); - } - - public static function validBooleanProvider(): array - { - return [ - 'true' => [true], - 'false' => [false], - 'string true' => ['true'], - 'string false' => ['false'], - 'string 1' => ['1'], - 'string 0' => ['0'], - 'string yes' => ['yes'], - 'string no' => ['no'], - 'string on' => ['on'], - 'string off' => ['off'], - 'uppercase TRUE' => ['TRUE'], - 'uppercase FALSE' => ['FALSE'], - ]; - } - - /** - * @dataProvider invalidBooleanProvider - */ - public function testDetectInvalidBoolean($input): void - { - $this->assertNull($this->detector->detect($input)); - } - - public static function invalidBooleanProvider(): array - { - return [ - 'string' => ['not a boolean'], - 'number' => [42], - 'array' => [[1, 2, 3]], - 'null' => [null], - 'object' => [new \stdClass()], - ]; - } - - public function testGetPriority(): void - { - $this->assertSame(80, $this->detector->getPriority()); - } -} diff --git a/tests/Unit/Type/Detector/DotenvTypeDetectorRegistryTest.php b/tests/Unit/Type/Detector/DotenvTypeDetectorRegistryTest.php deleted file mode 100644 index 031ec1c..0000000 --- a/tests/Unit/Type/Detector/DotenvTypeDetectorRegistryTest.php +++ /dev/null @@ -1,122 +0,0 @@ -registry = new DotenvTypeDetectorRegistry(); - } - - public function testRegisterDetector(): void - { - $mockDetector = $this->createMock(TypeDetector::class); - $mockDetector->method('getPriority')->willReturn(100); - $mockDetector->method('detect')->willReturn('mock_type'); - - $this->registry->registerDetector($mockDetector); - - $result = $this->registry->detectType('test_value'); - $this->assertSame('mock_type', $result); - } - - public function testDetectTypeWithMultipleDetectors(): void - { - $mockDetector1 = $this->createMock(TypeDetector::class); - $mockDetector1->method('getPriority')->willReturn(50); - $mockDetector1->method('detect')->willReturn(null); - - $mockDetector2 = $this->createMock(TypeDetector::class); - $mockDetector2->method('getPriority')->willReturn(100); - $mockDetector2->method('detect')->willReturn('high_priority_type'); - - $mockDetector3 = $this->createMock(TypeDetector::class); - $mockDetector3->method('getPriority')->willReturn(75); - $mockDetector3->method('detect')->willReturn('medium_priority_type'); - - $this->registry->registerDetector($mockDetector1); - $this->registry->registerDetector($mockDetector2); - $this->registry->registerDetector($mockDetector3); - - $result = $this->registry->detectType('test_value'); - $this->assertSame('high_priority_type', $result); - } - - public function testDetectTypeWithNoMatchingDetectors(): void - { - $mockDetector = $this->createMock(TypeDetector::class); - $mockDetector->method('getPriority')->willReturn(100); - $mockDetector->method('detect')->willReturn(null); - - $this->registry->registerDetector($mockDetector); - - $result = $this->registry->detectType('test_value'); - $this->assertSame('string', $result, "Should fallback to 'string' when no detector matches"); - } - - public function testFallbackToStringForUnrecognizedTypes(): void - { - // Remove all default detectors - $reflectionProperty = new \ReflectionProperty(DotenvTypeDetectorRegistry::class, 'detectors'); - $reflectionProperty->setAccessible(true); - $reflectionProperty->setValue($this->registry, new ArrayList()); - - $unrecognizedValues = [ - 'complex_value' => new \stdClass(), - 'resource' => fopen('php://memory', 'r'), - 'closure' => function () {}, - ]; - - foreach ($unrecognizedValues as $description => $value) { - $result = $this->registry->detectType($value); - $this->assertSame('string', $result, "Should fallback to 'string' for $description"); - } - } - - public function testDefaultDetectors(): void - { - $testCases = [ - 'array' => '[1,2,3]', - 'json' => '{"key":"value"}', - 'null' => 'null', - 'boolean' => 'true', - 'integer' => '42', - 'float' => '3.14', - 'string' => 'hello world', - ]; - - foreach ($testCases as $expectedType => $value) { - $detectedType = $this->registry->detectType($value); - $this->assertSame($expectedType, $detectedType, "Failed to detect {$expectedType} for value: {$value}"); - } - } - - public function testDetectorPrioritization(): void - { - $lowPriorityDetector = $this->createMock(TypeDetector::class); - $lowPriorityDetector->method('getPriority')->willReturn(50); - $lowPriorityDetector->method('detect')->willReturn('low_priority_type'); - - $highPriorityDetector = $this->createMock(TypeDetector::class); - $highPriorityDetector->method('getPriority')->willReturn(150); - $highPriorityDetector->method('detect')->willReturn('high_priority_type'); - - $this->registry->registerDetector($lowPriorityDetector); - $this->registry->registerDetector($highPriorityDetector); - - $result = $this->registry->detectType('test_value'); - $this->assertSame('high_priority_type', $result); - } -} diff --git a/tests/Unit/Type/Detector/JsonDetectorTest.php b/tests/Unit/Type/Detector/JsonDetectorTest.php deleted file mode 100644 index e381382..0000000 --- a/tests/Unit/Type/Detector/JsonDetectorTest.php +++ /dev/null @@ -1,96 +0,0 @@ -detector = new JsonDetector(); - } - - /** - * @dataProvider validJsonProvider - */ - public function testDetectValidJson(string $input): void - { - $this->assertSame('json', $this->detector->detect($input)); - } - - public static function validJsonProvider(): array - { - return [ - 'simple object' => ['{"key": "value"}'], - 'nested object' => ['{"outer": {"inner": "value"}}'], - 'array of objects' => ['[{"id": 1}, {"id": 2}]'], - 'complex json' => ['{"name": "John", "age": 30, "city": "New York", "hobbies": ["reading", "cycling"]}'], - 'empty object' => ['{}'], - 'empty array' => ['[]'], - ]; - } - - /** - * @dataProvider invalidJsonProvider - */ - public function testDetectInvalidJson($input): void - { - $this->assertNull($this->detector->detect($input)); - } - - public static function invalidJsonProvider(): array - { - return [ - 'string' => ['not a json'], - 'number' => [42], - 'boolean' => [true], - 'null' => [null], - 'array' => [[1, 2, 3]], - 'invalid json string' => ['{key: "value"}'], - 'incomplete json' => ['{"key": "value"'], - ]; - } - - /** - * @dataProvider jsonArrayOfObjectsProvider - */ - public function testIsJsonArrayOfObjects(string $input, bool $expected): void - { - $reflectionMethod = new \ReflectionMethod(JsonDetector::class, 'isJsonArrayOfObjects'); - $reflectionMethod->setAccessible(true); - - $this->assertSame($expected, $reflectionMethod->invoke($this->detector, $input)); - } - - public static function jsonArrayOfObjectsProvider(): array - { - return [ - 'valid array of objects' => ['[{"id": 1}, {"id": 2}]', true], - 'empty array' => ['[]', true], - 'array with single object' => ['[{"id": 1}]', true], - 'array of mixed types' => ['[{"id": 1}, 2, "string"]', false], - 'array of arrays' => ['[[1, 2], [3, 4]]', false], - 'simple array' => ['[1, 2, 3]', false], - 'object, not array' => ['{"key": "value"}', false], - 'invalid json' => ['not json', false], - 'malformed json array' => ['[{"id": 1}, {"id": 2]', false], - 'json number' => ['42', false], - 'json string' => ['"string"', false], - 'json true' => ['true', false], - 'json false' => ['false', false], - 'json null' => ['null', false], - ]; - } - - public function testGetPriority(): void - { - $this->assertSame(90, $this->detector->getPriority()); - } -} diff --git a/tests/Unit/Type/Detector/NullDetectorTest.php b/tests/Unit/Type/Detector/NullDetectorTest.php deleted file mode 100644 index 8378576..0000000 --- a/tests/Unit/Type/Detector/NullDetectorTest.php +++ /dev/null @@ -1,60 +0,0 @@ -detector = new NullDetector(); - } - - /** - * @dataProvider validNullProvider - */ - public function testDetectValidNull($input): void - { - $this->assertSame('null', $this->detector->detect($input)); - } - - public static function validNullProvider(): array - { - return [ - 'null value' => [null], - 'null string' => ['null'], - 'empty string' => [''], - ]; - } - - /** - * @dataProvider invalidNullProvider - */ - public function testDetectInvalidNull($input): void - { - $this->assertNull($this->detector->detect($input)); - } - - public static function invalidNullProvider(): array - { - return [ - 'string' => ['not null'], - 'number' => ['42'], - 'boolean' => ['true'], - 'array' => ['[1, 2, 3]'], - 'object' => ['{}'], - ]; - } - - public function testGetPriority(): void - { - $this->assertSame(95, $this->detector->getPriority()); - } -} diff --git a/tests/Unit/Type/Detector/NumericDetectorTest.php b/tests/Unit/Type/Detector/NumericDetectorTest.php deleted file mode 100644 index 3f74ecb..0000000 --- a/tests/Unit/Type/Detector/NumericDetectorTest.php +++ /dev/null @@ -1,64 +0,0 @@ -detector = new NumericDetector(); - } - - /** - * @dataProvider validNumericProvider - */ - public function testDetectValidNumeric($input, string $expected): void - { - $this->assertSame($expected, $this->detector->detect($input)); - } - - public static function validNumericProvider(): array - { - return [ - 'integer' => ['42', 'integer'], - 'negative integer' => ['-42', 'integer'], - 'zero' => ['0', 'integer'], - 'float' => ['3.14', 'float'], - 'negative float' => ['-3.14', 'float'], - 'string integer' => ['42', 'integer'], - 'string float' => ['3.14', 'float'], - ]; - } - - /** - * @dataProvider invalidNumericProvider - */ - public function testDetectInvalidNumeric($input): void - { - $this->assertNull($this->detector->detect($input)); - } - - public static function invalidNumericProvider(): array - { - return [ - 'string' => ['not a number'], - 'boolean' => [true], - 'null' => [null], - 'array' => [[1, 2, 3]], - 'object' => [new \stdClass()], - ]; - } - - public function testGetPriority(): void - { - $this->assertSame(70, $this->detector->getPriority()); - } -} diff --git a/tests/Unit/Type/Detector/StringDetectorTest.php b/tests/Unit/Type/Detector/StringDetectorTest.php deleted file mode 100644 index 28ff44e..0000000 --- a/tests/Unit/Type/Detector/StringDetectorTest.php +++ /dev/null @@ -1,63 +0,0 @@ -detector = new StringDetector(); - } - - /** - * @dataProvider validStringProvider - */ - public function testDetectValidString(string $input): void - { - $this->assertSame('string', $this->detector->detect($input)); - } - - public static function validStringProvider(): array - { - return [ - 'simple string' => ['hello'], - 'empty string' => [''], - 'numeric string' => ['42'], - 'special characters' => ['!@#$%^&*()'], - 'multi-line string' => ["line1\nline2"], - ]; - } - - /** - * @dataProvider invalidStringProvider - */ - public function testDetectInvalidString($input): void - { - $this->assertNull($this->detector->detect($input)); - } - - public static function invalidStringProvider(): array - { - return [ - 'integer' => [42], - 'float' => [3.14], - 'boolean' => [true], - 'null' => [null], - 'array' => [[1, 2, 3]], - 'object' => [new \stdClass()], - ]; - } - - public function testGetPriority(): void - { - $this->assertSame(10, $this->detector->getPriority()); - } -} diff --git a/tests/Unit/Type/Detector/Trait/ArrayParserTraitTest.php b/tests/Unit/Type/Detector/Trait/ArrayParserTraitTest.php deleted file mode 100644 index 98474bf..0000000 --- a/tests/Unit/Type/Detector/Trait/ArrayParserTraitTest.php +++ /dev/null @@ -1,34 +0,0 @@ -assertEquals($expected, $this->extractArrayElements($input)); - } - - public function testAllElementsMeet(): void - { - $elements = ['1', '2', '3']; - $condition = function ($element) { - return is_numeric($element); - }; - $this->assertTrue($this->allElementsMeet($elements, $condition)); - - $elements = ['1', 'two', '3']; - $this->assertFalse($this->allElementsMeet($elements, $condition)); - } -} diff --git a/tests/Unit/Type/Detector/Trait/StringValidatorTraitTest.php b/tests/Unit/Type/Detector/Trait/StringValidatorTraitTest.php deleted file mode 100644 index 1df88bd..0000000 --- a/tests/Unit/Type/Detector/Trait/StringValidatorTraitTest.php +++ /dev/null @@ -1,34 +0,0 @@ -assertTrue($this->isStringInput('test')); - $this->assertFalse($this->isStringInput(123)); - $this->assertFalse($this->isStringInput([])); - } - - public function testRemoveWhitespace(): void - { - $this->assertEquals('test', $this->removeWhitespace(' test ')); - $this->assertEquals('test', $this->removeWhitespace("\ttest\n")); - } - - public function testHasDelimiters(): void - { - $this->assertTrue($this->hasDelimiters('[test]', '[', ']')); - $this->assertFalse($this->hasDelimiters('test', '[', ']')); - $this->assertFalse($this->hasDelimiters('[test', '[', ']')); - $this->assertFalse($this->hasDelimiters('test]', '[', ']')); - } -} diff --git a/tests/Unit/Type/TypeSystemTest.php b/tests/Unit/Type/TypeSystemTest.php new file mode 100644 index 0000000..9a4395b --- /dev/null +++ b/tests/Unit/Type/TypeSystemTest.php @@ -0,0 +1,205 @@ +typeSystem = new TypeSystem(); + } + + // ── Null Detection ──────────────────────────────────────────────── + + public function testDetectsNull(): void + { + self::assertSame(ValueType::Null, $this->typeSystem->detect('null')); + self::assertSame(ValueType::Null, $this->typeSystem->detect('NULL')); + self::assertSame(ValueType::Null, $this->typeSystem->detect('(null)')); + } + + public function testEmptyStringIsNotNull(): void + { + self::assertSame(ValueType::String, $this->typeSystem->detect('')); + self::assertSame('', $this->typeSystem->resolve('')); + } + + public function testCastsNull(): void + { + self::assertNull($this->typeSystem->resolve('null')); + self::assertNull($this->typeSystem->resolve('NULL')); + self::assertNull($this->typeSystem->resolve('(null)')); + } + + // ── Boolean Detection ───────────────────────────────────────────── + + public function testDetectsBoolean(): void + { + self::assertSame(ValueType::Boolean, $this->typeSystem->detect('true')); + self::assertSame(ValueType::Boolean, $this->typeSystem->detect('false')); + self::assertSame(ValueType::Boolean, $this->typeSystem->detect('TRUE')); + self::assertSame(ValueType::Boolean, $this->typeSystem->detect('FALSE')); + self::assertSame(ValueType::Boolean, $this->typeSystem->detect('yes')); + self::assertSame(ValueType::Boolean, $this->typeSystem->detect('no')); + self::assertSame(ValueType::Boolean, $this->typeSystem->detect('on')); + self::assertSame(ValueType::Boolean, $this->typeSystem->detect('off')); + } + + public function testDetectsBooleanMixedCase(): void + { + self::assertSame(ValueType::Boolean, $this->typeSystem->detect('True')); + self::assertSame(ValueType::Boolean, $this->typeSystem->detect('False')); + self::assertSame(ValueType::Boolean, $this->typeSystem->detect('Yes')); + self::assertSame(ValueType::Boolean, $this->typeSystem->detect('No')); + self::assertSame(ValueType::Boolean, $this->typeSystem->detect('On')); + self::assertSame(ValueType::Boolean, $this->typeSystem->detect('Off')); + } + + public function testCastsBooleanTrue(): void + { + self::assertTrue($this->typeSystem->resolve('true')); + self::assertTrue($this->typeSystem->resolve('TRUE')); + self::assertTrue($this->typeSystem->resolve('yes')); + self::assertTrue($this->typeSystem->resolve('on')); + self::assertTrue($this->typeSystem->resolve('(true)')); + } + + public function testCastsBooleanFalse(): void + { + self::assertFalse($this->typeSystem->resolve('false')); + self::assertFalse($this->typeSystem->resolve('FALSE')); + self::assertFalse($this->typeSystem->resolve('no')); + self::assertFalse($this->typeSystem->resolve('off')); + } + + // ── Integer Detection ───────────────────────────────────────────── + + public function testDetectsInteger(): void + { + self::assertSame(ValueType::Integer, $this->typeSystem->detect('42')); + self::assertSame(ValueType::Integer, $this->typeSystem->detect('0')); + self::assertSame(ValueType::Integer, $this->typeSystem->detect('-10')); + self::assertSame(ValueType::Integer, $this->typeSystem->detect('+99')); + } + + public function testCastsInteger(): void + { + self::assertSame(42, $this->typeSystem->resolve('42')); + self::assertSame(0, $this->typeSystem->resolve('0')); + self::assertSame(-10, $this->typeSystem->resolve('-10')); + } + + // ── Float Detection ─────────────────────────────────────────────── + + public function testDetectsFloat(): void + { + self::assertSame(ValueType::Float, $this->typeSystem->detect('3.14')); + self::assertSame(ValueType::Float, $this->typeSystem->detect('-0.5')); + self::assertSame(ValueType::Float, $this->typeSystem->detect('1e10')); + self::assertSame(ValueType::Float, $this->typeSystem->detect('2.5E-3')); + } + + public function testCastsFloat(): void + { + self::assertSame(3.14, $this->typeSystem->resolve('3.14')); + self::assertSame(-0.5, $this->typeSystem->resolve('-0.5')); + } + + // ── JSON Detection ──────────────────────────────────────────────── + + public function testDetectsJson(): void + { + self::assertSame(ValueType::Json, $this->typeSystem->detect('{"key": "value"}')); + self::assertSame(ValueType::Json, $this->typeSystem->detect('{"nested": {"a": 1}}')); + } + + public function testCastsJson(): void + { + $result = $this->typeSystem->resolve('{"key": "value", "num": 42}'); + + self::assertIsArray($result); + self::assertSame('value', $result['key']); + self::assertSame(42, $result['num']); + } + + public function testInvalidJsonNotDetected(): void + { + self::assertSame(ValueType::String, $this->typeSystem->detect('{invalid json}')); + } + + // ── Array Detection ─────────────────────────────────────────────── + + public function testDetectsArray(): void + { + self::assertSame(ValueType::Array, $this->typeSystem->detect('["a", "b", "c"]')); + self::assertSame(ValueType::Array, $this->typeSystem->detect('[1, 2, 3]')); + } + + public function testCastsArray(): void + { + $result = $this->typeSystem->resolve('["item1", "item2"]'); + + self::assertIsArray($result); + self::assertSame(['item1', 'item2'], $result); + } + + // ── String Fallback ─────────────────────────────────────────────── + + public function testFallsBackToString(): void + { + self::assertSame(ValueType::String, $this->typeSystem->detect('hello world')); + self::assertSame(ValueType::String, $this->typeSystem->detect('/usr/local/bin')); + self::assertSame(ValueType::String, $this->typeSystem->detect('https://kariricode.org')); + } + + public function testStringValuesReturnedAsIs(): void + { + self::assertSame('hello world', $this->typeSystem->resolve('hello world')); + } + + // ── Custom Detector/Caster ──────────────────────────────────────── + + public function testCustomDetectorIsRespected(): void + { + $customDetector = new class () implements TypeDetector { + public function priority(): int + { + return 999; // Highest priority + } + + public function detect(string $value): ?ValueType + { + return str_starts_with($value, 'CUSTOM:') ? ValueType::String : null; + } + }; + + $this->typeSystem->addDetector($customDetector); + + // Custom detector matches first, but returns String + self::assertSame(ValueType::String, $this->typeSystem->detect('CUSTOM:42')); + } + + public function testCustomCasterOverridesDefault(): void + { + $customCaster = new class () implements TypeCaster { + public function cast(string $value): int + { + return (int) $value * 100; + } + }; + + $this->typeSystem->addCaster(ValueType::Integer, $customCaster); + + self::assertSame(4200, $this->typeSystem->resolve('42')); + } +} diff --git a/tests/Unit/Validation/EnvironmentValidatorTest.php b/tests/Unit/Validation/EnvironmentValidatorTest.php new file mode 100644 index 0000000..b127f7f --- /dev/null +++ b/tests/Unit/Validation/EnvironmentValidatorTest.php @@ -0,0 +1,300 @@ + $variables[$name] ?? null, + ); + } + + // ── Required ────────────────────────────────────────────────────── + + public function testRequiredPassesWhenPresent(): void + { + $v = $this->makeValidator(['DB_HOST' => 'localhost']); + $v->required('DB_HOST')->assert(); + $this->assertTrue(true); + } + + public function testRequiredFailsWhenMissing(): void + { + $v = $this->makeValidator([]); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('DB_HOST is required'); + + $v->required('DB_HOST')->assert(); + } + + public function testMultipleRequiredReportsAll(): void + { + $v = $this->makeValidator(['DB_HOST' => 'localhost']); + + try { + $v->required('DB_HOST', 'DB_PORT', 'DB_NAME')->assert(); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertCount(2, $e->errors()); + $this->assertStringContainsString('DB_PORT', $e->getMessage()); + $this->assertStringContainsString('DB_NAME', $e->getMessage()); + } + } + + // ── NotEmpty ────────────────────────────────────────────────────── + + public function testNotEmptyPasses(): void + { + $v = $this->makeValidator(['DB_HOST' => 'localhost']); + $v->required('DB_HOST')->notEmpty()->assert(); + $this->assertTrue(true); + } + + public function testNotEmptyFails(): void + { + $v = $this->makeValidator(['DB_HOST' => ' ']); + + $this->expectException(ValidationException::class); + + $v->required('DB_HOST')->notEmpty()->assert(); + } + + // ── IsInteger ───────────────────────────────────────────────────── + + public function testIsIntegerPasses(): void + { + $v = $this->makeValidator(['DB_PORT' => '5432']); + $v->required('DB_PORT')->isInteger()->assert(); + $this->assertTrue(true); + } + + public function testIsIntegerFailsOnFloat(): void + { + $v = $this->makeValidator(['DB_PORT' => '54.32']); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('must be an integer'); + + $v->required('DB_PORT')->isInteger()->assert(); + } + + public function testIsIntegerAcceptsSignedNumbers(): void + { + $v = $this->makeValidator(['OFFSET' => '-10']); + $v->required('OFFSET')->isInteger()->assert(); + $this->assertTrue(true); + } + + // ── Between ─────────────────────────────────────────────────────── + + public function testBetweenPasses(): void + { + $v = $this->makeValidator(['PORT' => '8080']); + $v->required('PORT')->isInteger()->between(1, 65535)->assert(); + $this->assertTrue(true); + } + + public function testBetweenFailsBelowMin(): void + { + $v = $this->makeValidator(['PORT' => '0']); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('between 1 and 65535'); + + $v->required('PORT')->isInteger()->between(1, 65535)->assert(); + } + + public function testBetweenFailsAboveMax(): void + { + $v = $this->makeValidator(['PORT' => '70000']); + + $this->expectException(ValidationException::class); + + $v->required('PORT')->isInteger()->between(1, 65535)->assert(); + } + + // ── IsBoolean ───────────────────────────────────────────────────── + + public function testIsBooleanPassesForAllVariants(): void + { + foreach (['true', 'false', '1', '0', 'yes', 'no', 'on', 'off', 'TRUE', 'False'] as $val) { + $v = $this->makeValidator(['DEBUG' => $val]); + $v->required('DEBUG')->isBoolean()->assert(); + } + + $this->assertTrue(true); + } + + public function testIsBooleanFails(): void + { + $v = $this->makeValidator(['DEBUG' => 'maybe']); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('must be a boolean'); + + $v->required('DEBUG')->isBoolean()->assert(); + } + + // ── AllowedValues ───────────────────────────────────────────────── + + public function testAllowedValuesPasses(): void + { + $v = $this->makeValidator(['ENV' => 'production']); + $v->allowedValues('ENV', ['local', 'staging', 'production'])->assert(); + $this->assertTrue(true); + } + + public function testAllowedValuesFails(): void + { + $v = $this->makeValidator(['ENV' => 'invalid']); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('must be one of'); + + $v->allowedValues('ENV', ['local', 'staging', 'production'])->assert(); + } + + // ── MatchesRegex ────────────────────────────────────────────────── + + public function testMatchesRegexPasses(): void + { + $v = $this->makeValidator(['KEY' => 'abcdef0123456789abcdef0123456789']); + $v->matchesRegex('KEY', '/\A[a-f0-9]{32}\z/')->assert(); + $this->assertTrue(true); + } + + public function testMatchesRegexFails(): void + { + $v = $this->makeValidator(['KEY' => 'short']); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('must match pattern'); + + $v->matchesRegex('KEY', '/\A[a-f0-9]{32}\z/')->assert(); + } + + // ── URL ─────────────────────────────────────────────────────────── + + public function testUrlPasses(): void + { + $v = $this->makeValidator(['APP_URL' => 'https://example.com']); + $v->url('APP_URL')->assert(); + $this->assertTrue(true); + } + + public function testUrlFails(): void + { + $v = $this->makeValidator(['APP_URL' => 'not-a-url']); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('must be a valid URL'); + + $v->url('APP_URL')->assert(); + } + + // ── Email ───────────────────────────────────────────────────────── + + public function testEmailPasses(): void + { + $v = $this->makeValidator(['ADMIN' => 'admin@example.com']); + $v->email('ADMIN')->assert(); + $this->assertTrue(true); + } + + public function testEmailFails(): void + { + $v = $this->makeValidator(['ADMIN' => 'not-an-email']); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('must be a valid email'); + + $v->email('ADMIN')->assert(); + } + + // ── Custom ──────────────────────────────────────────────────────── + + public function testCustomCallbackPasses(): void + { + $v = $this->makeValidator(['DSN' => 'pgsql:host=localhost']); + $v->custom('DSN', fn (string $v): bool => str_starts_with($v, 'pgsql:'))->assert(); + $this->assertTrue(true); + } + + public function testCustomCallbackFails(): void + { + $v = $this->makeValidator(['DSN' => 'mysql:host=localhost']); + + $this->expectException(ValidationException::class); + + $v->custom('DSN', fn (string $v): bool => str_starts_with($v, 'pgsql:'), 'DSN must use pgsql driver.')->assert(); + } + + // ── IfPresent (conditional) ─────────────────────────────────────── + + public function testIfPresentSkipsMissingVariable(): void + { + $v = $this->makeValidator([]); + $v->ifPresent('REDIS_HOST')->notEmpty()->assert(); + $this->assertTrue(true); + } + + public function testIfPresentValidatesWhenPresent(): void + { + $v = $this->makeValidator(['REDIS_HOST' => '']); + + $this->expectException(ValidationException::class); + + $v->ifPresent('REDIS_HOST')->notEmpty()->assert(); + } + + // ── Batch Errors ────────────────────────────────────────────────── + + public function testBatchCollectsAllErrors(): void + { + $v = $this->makeValidator([ + 'PORT' => 'abc', + 'DEBUG' => 'maybe', + ]); + + try { + $v->required('PORT', 'DEBUG', 'MISSING') + ->isInteger('PORT') + ->isBoolean('DEBUG') + ->assert(); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + // MISSING is required, PORT is not integer, DEBUG is not boolean + $this->assertCount(3, $e->errors()); + } + } + + // ── IsNumeric ───────────────────────────────────────────────────── + + public function testIsNumericPasses(): void + { + foreach (['42', '3.14', '-0.5', '1e10'] as $val) { + $v = $this->makeValidator(['N' => $val]); + $v->required('N')->isNumeric()->assert(); + } + + $this->assertTrue(true); + } + + public function testIsNumericFails(): void + { + $v = $this->makeValidator(['N' => 'abc']); + + $this->expectException(ValidationException::class); + + $v->required('N')->isNumeric()->assert(); + } +} diff --git a/tests/Unit/ValueObject/DotenvConfigurationTest.php b/tests/Unit/ValueObject/DotenvConfigurationTest.php new file mode 100644 index 0000000..3fd7565 --- /dev/null +++ b/tests/Unit/ValueObject/DotenvConfigurationTest.php @@ -0,0 +1,170 @@ +loadMode); + self::assertFalse($config->strictNames); + self::assertTrue($config->typeCasting); + self::assertTrue($config->populateEnv); + self::assertTrue($config->populateServer); + self::assertFalse($config->usePutenv); + self::assertNull($config->encryptionKey); + self::assertNull($config->cachePath); + self::assertSame([], $config->allowList); + self::assertSame([], $config->denyList); + self::assertNull($config->environmentName); + } + + // ── Immutability ───────────────────────────────────────────────── + + public function testIsReadonly(): void + { + $config = new DotenvConfiguration(); + + $reflection = new \ReflectionClass($config); + self::assertTrue($reflection->isReadOnly()); + } + + public function testIsFinal(): void + { + $reflection = new \ReflectionClass(DotenvConfiguration::class); + self::assertTrue($reflection->isFinal()); + } + + // ── with* methods return new instances ─────────────────────────── + + public function testWithLoadModeReturnsNewInstance(): void + { + $original = new DotenvConfiguration(); + $modified = $original->withLoadMode(LoadMode::Overwrite); + + self::assertNotSame($original, $modified); + self::assertSame(LoadMode::Immutable, $original->loadMode); + self::assertSame(LoadMode::Overwrite, $modified->loadMode); + // Other fields preserved + self::assertSame($original->strictNames, $modified->strictNames); + self::assertSame($original->typeCasting, $modified->typeCasting); + } + + public function testWithStrictNamesReturnsNewInstance(): void + { + $original = new DotenvConfiguration(); + $modified = $original->withStrictNames(true); + + self::assertNotSame($original, $modified); + self::assertFalse($original->strictNames); + self::assertTrue($modified->strictNames); + self::assertSame($original->loadMode, $modified->loadMode); + } + + public function testWithTypeCastingReturnsNewInstance(): void + { + $original = new DotenvConfiguration(); + $modified = $original->withTypeCasting(false); + + self::assertNotSame($original, $modified); + self::assertTrue($original->typeCasting); + self::assertFalse($modified->typeCasting); + } + + public function testWithEncryptionKeyReturnsNewInstance(): void + { + $key = str_repeat('a', 64); + $original = new DotenvConfiguration(); + $modified = $original->withEncryptionKey($key); + + self::assertNotSame($original, $modified); + self::assertNull($original->encryptionKey); + self::assertSame($key, $modified->encryptionKey); + } + + public function testWithCachePathReturnsNewInstance(): void + { + $original = new DotenvConfiguration(); + $modified = $original->withCachePath('/tmp/.env.cache.php'); + + self::assertNotSame($original, $modified); + self::assertNull($original->cachePath); + self::assertSame('/tmp/.env.cache.php', $modified->cachePath); + } + + public function testWithAllowListReturnsNewInstance(): void + { + $original = new DotenvConfiguration(); + $modified = $original->withAllowList(['DB_*', 'APP_*']); + + self::assertNotSame($original, $modified); + self::assertSame([], $original->allowList); + self::assertSame(['DB_*', 'APP_*'], $modified->allowList); + } + + public function testWithDenyListReturnsNewInstance(): void + { + $original = new DotenvConfiguration(); + $modified = $original->withDenyList(['SECRET*']); + + self::assertNotSame($original, $modified); + self::assertSame([], $original->denyList); + self::assertSame(['SECRET*'], $modified->denyList); + } + + public function testWithEnvironmentNameReturnsNewInstance(): void + { + $original = new DotenvConfiguration(); + $modified = $original->withEnvironmentName('production'); + + self::assertNotSame($original, $modified); + self::assertNull($original->environmentName); + self::assertSame('production', $modified->environmentName); + } + + // ── Chaining preserves all fields ──────────────────────────────── + + public function testChainingPreservesAllFields(): void + { + $config = new DotenvConfiguration() + ->withLoadMode(LoadMode::Overwrite) + ->withStrictNames(true) + ->withTypeCasting(false) + ->withEnvironmentName('staging'); + + self::assertSame(LoadMode::Overwrite, $config->loadMode); + self::assertTrue($config->strictNames); + self::assertFalse($config->typeCasting); + self::assertSame('staging', $config->environmentName); + // Untouched defaults preserved + self::assertNull($config->encryptionKey); + self::assertSame([], $config->allowList); + } + + // ── Constructor override ────────────────────────────────────────── + + public function testConstructorOverridesDefaults(): void + { + $config = new DotenvConfiguration( + loadMode: LoadMode::SkipExisting, + strictNames: true, + typeCasting: false, + encryptionKey: 'abc', + ); + + self::assertSame(LoadMode::SkipExisting, $config->loadMode); + self::assertTrue($config->strictNames); + self::assertFalse($config->typeCasting); + self::assertSame('abc', $config->encryptionKey); + } +} diff --git a/tests/Unit/ValueObject/EnvironmentVariableTest.php b/tests/Unit/ValueObject/EnvironmentVariableTest.php new file mode 100644 index 0000000..af446b8 --- /dev/null +++ b/tests/Unit/ValueObject/EnvironmentVariableTest.php @@ -0,0 +1,95 @@ +name); + self::assertSame('5432', $var->rawValue); + self::assertSame(ValueType::Integer, $var->type); + self::assertSame(5432, $var->value); + self::assertSame('.env.local', $var->source); + self::assertTrue($var->overridden); + } + + public function testDefaultSourceAndOverridden(): void + { + $var = new EnvironmentVariable( + name: 'APP_NAME', + rawValue: 'KaririCode', + type: ValueType::String, + value: 'KaririCode', + ); + + self::assertSame('', $var->source); + self::assertFalse($var->overridden); + } + + public function testIsReadonly(): void + { + $reflection = new \ReflectionClass(EnvironmentVariable::class); + self::assertTrue($reflection->isReadOnly()); + } + + public function testIsFinal(): void + { + $reflection = new \ReflectionClass(EnvironmentVariable::class); + self::assertTrue($reflection->isFinal()); + } + + public function testNullValueIsAllowed(): void + { + $var = new EnvironmentVariable( + name: 'OPTIONAL', + rawValue: 'null', + type: ValueType::Null, + value: null, + ); + + self::assertNull($var->value); + self::assertSame(ValueType::Null, $var->type); + } + + public function testComplexTypedValues(): void + { + $array = ['a', 'b']; + $var = new EnvironmentVariable( + name: 'ITEMS', + rawValue: '["a","b"]', + type: ValueType::Array, + value: $array, + ); + + self::assertSame($array, $var->value); + self::assertSame(ValueType::Array, $var->type); + } + + public function testBooleanValue(): void + { + $var = new EnvironmentVariable( + name: 'DEBUG', + rawValue: 'true', + type: ValueType::Boolean, + value: true, + ); + + self::assertTrue($var->value); + } +} diff --git a/tests/application.php b/tests/application.php deleted file mode 100644 index d247976..0000000 --- a/tests/application.php +++ /dev/null @@ -1,87 +0,0 @@ -load(); - - $variables = [ - 'KARIRI_APP_ENV' => 'string', - 'KARIRI_APP_NAME' => 'string', - 'KARIRI_PHP_VERSION' => 'double', - 'KARIRI_PHP_PORT' => 'integer', - 'KARIRI_APP_DEBUG' => 'boolean', - 'KARIRI_APP_URL' => 'string', - 'KARIRI_DB_CONNECTION' => 'string', - 'KARIRI_DB_HOST' => 'string', - 'KARIRI_DB_PORT' => 'integer', - 'KARIRI_DB_DATABASE' => 'string', - 'KARIRI_DB_USERNAME' => 'string', - 'KARIRI_DB_PASSWORD' => 'string', - 'KARIRI_CACHE_DRIVER' => 'string', - 'KARIRI_SESSION_LIFETIME' => 'integer', - 'KARIRI_MAIL_MAILER' => 'string', - 'KARIRI_MAIL_HOST' => 'string', - 'KARIRI_MAIL_PORT' => 'integer', - 'KARIRI_MAIL_USERNAME' => 'NULL', - 'KARIRI_MAIL_PASSWORD' => 'NULL', - 'KARIRI_MAIL_ENCRYPTION' => 'NULL', - 'KARIRI_MAIL_FROM_ADDRESS' => 'NULL', - 'KARIRI_MAIL_FROM_NAME' => 'string', - 'KARIRI_ARRAY_CONFIG' => 'array', - 'KARIRI_JSON_CONFIG' => 'json', - ]; - - foreach ($variables as $key => $expectedType) { - testEnvVariable($key, $expectedType); - } - - // Test variable interpolation - $appName = env('KARIRI_APP_NAME'); - $mailFromName = env('KARIRI_MAIL_FROM_NAME'); - echo $appName === $mailFromName - ? "Variable interpolation for KARIRI_MAIL_FROM_NAME works correctly.\n" - : "WARNING: Variable interpolation for KARIRI_MAIL_FROM_NAME failed.\n"; -} catch (DotenvException $e) { - echo 'An error occurred: ' . $e->getMessage() . "\n"; -} - -function testEnvVariable(string $key, string $expectedType): void -{ - $value = env($key); - $actualType = determineActualType($value, $expectedType); - - outputTestResult($key, $value, $expectedType, $actualType); - - if ($actualType !== $expectedType) { - echo "WARNING: Type mismatch for $key\n"; - } -} - -function determineActualType(mixed $value, string $expectedType): string -{ - if ('json' === $expectedType && (is_array($value) || is_object($value))) { - return 'json'; - } - - return gettype($value); -} - -function outputTestResult(string $key, mixed $value, string $expectedType, string $actualType): void -{ - $displayValue = is_array($value) || is_object($value) ? json_encode($value) : var_export($value, true); - echo sprintf( - "%s: %s (Expected: %s, Actual: %s)\n", - $key, - $displayValue, - $expectedType, - $actualType - ); -}