diff --git a/src/Internal/Docblock/DocblockNormalizer.php b/src/Internal/Docblock/DocblockNormalizer.php index 1fe2892..5f29646 100644 --- a/src/Internal/Docblock/DocblockNormalizer.php +++ b/src/Internal/Docblock/DocblockNormalizer.php @@ -9,6 +9,7 @@ * Converts class-specific shapes like stdClass{id: int} into intersection shapes (stdClass & object{id: int}). * Normalizes variadic tuple spread syntax (...Type[] and ...list) into PHPStan-compliant ... syntax. * Normalizes unqualified wildcard bitmasks (int-mask-of) into quoted string literals. + * Normalizes unparenthesized nested callable return types into right-associative parenthesized callables. * * @internal */ @@ -52,7 +53,13 @@ public static function normalize(string $doc): string } if (stripos($doc, 'callable') !== false || stripos($doc, 'closure') !== false) { + // 1. Auto-complete omitted return types to ': mixed' $doc = preg_replace('/(callable|Closure|static-closure)\s*(\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\))(?!\s*:)/i', '$1$2: mixed', $doc) ?? $doc; + + // 2. Auto-parenthesize unparenthesized nested return callables (Mago / Right-associative functional currying) + if (str_contains($doc, ':')) { + $doc = self::normalizeNestedCallables($doc); + } } if (str_contains($doc, '::') && str_contains($doc, ':')) { @@ -75,6 +82,108 @@ public static function normalize(string $doc): string return self::normalizeShapes($doc); } + /** + * Normalizes unparenthesized nested callable return types into right-associative parenthesized callables: + * callable(int): callable(string): bool ==> callable(int): (callable(string): bool) + */ + private static function normalizeNestedCallables(string $doc): string + { + $len = \strlen($doc); + $result = ''; + $i = 0; + + while ($i < $len) { + if ($doc[$i] === ':') { + $j = $i + 1; + while ($j < $len && ($doc[$j] === ' ' || $doc[$j] === "\t")) { + $j++; + } + + if ($j < $len && $doc[$j] === '(') { + $result .= $doc[$i]; + $i++; + + continue; + } + + $remaining = substr($doc, $j); + if (preg_match('/^(callable|Closure|static-closure)\s*\(/i', $remaining) === 1) { + $result .= ': ('; + + $k = $j; + $parenDepth = 0; + $braceDepth = 0; + $angleDepth = 0; + $bracketDepth = 0; + + while ($k < $len) { + $char = $doc[$k]; + + if ($char === '(') { + $parenDepth++; + } elseif ($char === ')') { + if ($parenDepth === 0) { + break; + } + $parenDepth--; + } elseif ($char === '{') { + $braceDepth++; + } elseif ($char === '}') { + if ($braceDepth === 0) { + break; + } + $braceDepth--; + } elseif ($char === '<') { + $angleDepth++; + } elseif ($char === '>') { + if ($angleDepth === 0) { + break; + } + $angleDepth--; + } elseif ($char === '[') { + $bracketDepth++; + } elseif ($char === ']') { + if ($bracketDepth === 0) { + break; + } + $bracketDepth--; + } elseif ($char === ',' && $parenDepth === 0 && $braceDepth === 0 && $angleDepth === 0 && $bracketDepth === 0) { + break; + } elseif (($char === ' ' || $char === "\t" || $char === "\r" || $char === "\n") && $parenDepth === 0 && $braceDepth === 0 && $angleDepth === 0 && $bracketDepth === 0) { + $afterWs = substr($doc, $k); + + if (preg_match('/^\s+(\$[a-zA-Z_\x80-\xff]|\*\/|\*|\r|\n)/', $afterWs) === 1) { + break; + } + + if (preg_match('/^\s+([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\s*\(/', $afterWs, $nameMatch) === 1) { + $word = strtolower($nameMatch[1]); + if (! \in_array($word, ['callable', 'closure', 'static-closure', 'array', 'list', 'object', 'iterable'], true)) { + break; + } + } + } + + $k++; + } + + $nestedCallableSubstring = substr($doc, $j, $k - $j); + $normalizedNested = self::normalizeNestedCallables($nestedCallableSubstring); + + $result .= $normalizedNested . ')'; + $i = $k; + + continue; + } + } + + $result .= $doc[$i]; + $i++; + } + + return $result; + } + /** * Normalizes custom class shapes supporting arbitrary nested balanced braces via PCRE recursion. */ diff --git a/src/Internal/Util/Config.php b/src/Internal/Util/Config.php index a090758..c6db5c1 100644 --- a/src/Internal/Util/Config.php +++ b/src/Internal/Util/Config.php @@ -93,8 +93,52 @@ final class Config private static int $ignoreTraceDepth = 25; + private static function getEnvValue(string $key): ?string + { + if (isset($_ENV[$key])) { + $val = $_ENV[$key]; + } elseif (isset($_SERVER[$key])) { + $val = $_SERVER[$key]; + } else { + $env = getenv($key); + $val = $env !== false ? $env : null; + } + + if ($val === null) { + return null; + } + + if (\is_string($val)) { + return $val; + } + + if (\is_scalar($val)) { + return (string) $val; + } + + return null; + } + public static function isEnabled(): bool { + $envDisable = self::getEnvValue('TYPEPHP_DISABLE'); + if ($envDisable !== null && trim($envDisable) !== '') { + if (filter_var($envDisable, FILTER_VALIDATE_BOOLEAN)) { + return false; + } + } + + $envEnabled = self::getEnvValue('TYPEPHP_ENABLED'); + if ($envEnabled !== null && trim($envEnabled) !== '') { + if (! filter_var($envEnabled, FILTER_VALIDATE_BOOLEAN)) { + return false; + } + } + + if (\defined('TYPEPHP_DISABLE') && TYPEPHP_DISABLE) { + return false; + } + if (self::$cachedConfig === null) { self::get(); } @@ -104,8 +148,8 @@ public static function isEnabled(): bool public static function isAutoBootEnabled(): bool { - $envAutoBoot = getenv('TYPEPHP_AUTO_BOOT'); - if ($envAutoBoot !== false && trim((string) $envAutoBoot) !== '') { + $envAutoBoot = self::getEnvValue('TYPEPHP_AUTO_BOOT'); + if ($envAutoBoot !== null && trim($envAutoBoot) !== '') { return filter_var($envAutoBoot, FILTER_VALIDATE_BOOLEAN); } @@ -118,9 +162,9 @@ public static function isAutoBootEnabled(): bool public static function getOnViolation(): string { - $envMode = getenv('TYPEPHP_ON_VIOLATION'); - if ($envMode !== false && trim((string) $envMode) !== '') { - $normalized = strtolower(trim((string) $envMode)); + $envMode = self::getEnvValue('TYPEPHP_ON_VIOLATION'); + if ($envMode !== null && trim($envMode) !== '') { + $normalized = strtolower(trim($envMode)); if ($normalized === 'warning') { return 'warn'; } @@ -137,9 +181,9 @@ public static function getOnViolation(): string public static function getReportFile(): ?string { - $envFile = getenv('TYPEPHP_REPORT_FILE'); - if ($envFile !== false && trim((string) $envFile) !== '') { - return trim((string) $envFile); + $envFile = self::getEnvValue('TYPEPHP_REPORT_FILE'); + if ($envFile !== null && trim($envFile) !== '') { + return trim($envFile); } if (self::$cachedConfig === null) { @@ -151,8 +195,8 @@ public static function getReportFile(): ?string public static function isFailOnReportEnabled(): bool { - $envFail = getenv('TYPEPHP_FAIL_ON_REPORT'); - if ($envFail !== false && trim((string) $envFail) !== '') { + $envFail = self::getEnvValue('TYPEPHP_FAIL_ON_REPORT'); + if ($envFail !== null && trim($envFail) !== '') { return filter_var($envFail, FILTER_VALIDATE_BOOLEAN); } @@ -165,8 +209,8 @@ public static function isFailOnReportEnabled(): bool public static function isRedactValuesEnabled(): bool { - $envRedact = getenv('TYPEPHP_REDACT_VALUES'); - if ($envRedact !== false && trim((string) $envRedact) !== '') { + $envRedact = self::getEnvValue('TYPEPHP_REDACT_VALUES'); + if ($envRedact !== null && trim($envRedact) !== '') { return filter_var($envRedact, FILTER_VALIDATE_BOOLEAN); } diff --git a/src/bootstrap.php b/src/bootstrap.php index dc3cd26..2fef4df 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -9,7 +9,11 @@ if (class_exists(TypePHP::class) && ! \defined('TYPEPHP_BOOTED')) { \define('TYPEPHP_BOOTED', true); - $isDisabledEnv = getenv('TYPEPHP_DISABLE') !== false && filter_var(getenv('TYPEPHP_DISABLE'), FILTER_VALIDATE_BOOLEAN); + $isDisabledEnv = ( + (getenv('TYPEPHP_DISABLE') !== false && filter_var(getenv('TYPEPHP_DISABLE'), FILTER_VALIDATE_BOOLEAN)) + || (isset($_ENV['TYPEPHP_DISABLE']) && filter_var($_ENV['TYPEPHP_DISABLE'], FILTER_VALIDATE_BOOLEAN)) + || (isset($_SERVER['TYPEPHP_DISABLE']) && filter_var($_SERVER['TYPEPHP_DISABLE'], FILTER_VALIDATE_BOOLEAN)) + ); $isDisabledConst = \defined('TYPEPHP_DISABLE') && TYPEPHP_DISABLE; $isTooling = false; diff --git a/tests/Internal/Docblock/DocblockNormalizerTest.php b/tests/Internal/Docblock/DocblockNormalizerTest.php index 0aaa735..5fb4837 100644 --- a/tests/Internal/Docblock/DocblockNormalizerTest.php +++ b/tests/Internal/Docblock/DocblockNormalizerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use TypePHP\Internal\Docblock\DocblockExtractor; use TypePHP\Internal\Docblock\DocblockNormalizer; describe('DocblockNormalizer', function () { @@ -33,6 +34,248 @@ }); }); + describe('Nested and Curried Callables / Closures Normalization (Mago & Functional Syntax)', function () { + describe('1. Basic 2-Level Nested Callables', function () { + test('normalizes unparenthesized 2-level callable (callable -> callable)', function () { + $doc = '/** @param callable(int): callable(string): bool $factory */'; + $expected = '/** @param callable(int): (callable(string): bool) $factory */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes unparenthesized 2-level Closure (Closure -> Closure)', function () { + $doc = '/** @param Closure(int): Closure(string): bool $factory */'; + $expected = '/** @param Closure(int): (Closure(string): bool) $factory */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes unparenthesized static-closure variants', function () { + $doc = '/** @param static-closure(int): static-closure(string): bool $factory */'; + $expected = '/** @param static-closure(int): (static-closure(string): bool) $factory */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes mixed callable, Closure, and static-closure combinations', function () { + $doc = '/** @param callable(int): Closure(string): static-closure(float): bool $fn */'; + $expected = '/** @param callable(int): (Closure(string): (static-closure(float): bool)) $fn */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes parenthesized (static Closure) inside nested callable return type', function () { + $doc = '/** @param callable(int): (static Closure)(string): bool $factory */'; + $expected = '/** @param callable(int): (static-closure(string): bool) $factory */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes bare static Closure inside nested callable return type', function () { + $doc = '/** @param callable(int): static Closure(string): bool $factory */'; + $expected = '/** @param callable(int): (static-closure(string): bool) $factory */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes 3-level currying with mixed static Closure and callable syntax', function () { + $doc = '/** @param (static Closure)(int): static Closure(string): (static Closure)(float): int $chain */'; + $expected = '/** @param static-closure(int): (static-closure(string): (static-closure(float): int)) $chain */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes higher-order callable taking parenthesized (static Closure) argument', function () { + $doc = '/** @param callable((static Closure)(int): bool): string $cb */'; + $expected = '/** @param callable(static-closure(int): bool): string $cb */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + }); + + describe('2. Multi-Level Deep Currying (3+ Levels)', function () { + test('normalizes 3-level curried callable pipeline', function () { + $doc = '/** @param callable(int): callable(string): callable(float): int $chain */'; + $expected = '/** @param callable(int): (callable(string): (callable(float): int)) $chain */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes 4-level deeply nested curried callable pipeline', function () { + $doc = '/** @param callable(int): callable(string): callable(float): callable(bool): string $deep */'; + $expected = '/** @param callable(int): (callable(string): (callable(float): (callable(bool): string))) $deep */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + }); + + describe('3. Parameter Signatures (Named, Variadic, and Optional)', function () { + test('normalizes nested callables with named parameters ($threshold, $s)', function () { + $doc = '/** @param callable(int $threshold): callable(string $s): bool $factory */'; + $expected = '/** @param callable(int $threshold): (callable(string $s): bool) $factory */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes nested callables with variadic and optional parameters', function () { + $doc = '/** @param callable(int ...$ids): callable(string, ?float=): bool $fn */'; + $expected = '/** @param callable(int ...$ids): (callable(string, ?float=): bool) $fn */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes nested callables taking multiple arguments per stage', function () { + $doc = '/** @param callable(int, string): callable(float, bool, array): int $fn */'; + $expected = '/** @param callable(int, string): (callable(float, bool, array): int) $fn */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + }); + + describe('4. Complex Nested Return Types (Shapes, Unions, Generics, Nullables)', function () { + test('normalizes nested callable returning a parenthesized union', function () { + $doc = '/** @param callable(int): callable(string): (bool|int) $fn */'; + $expected = '/** @param callable(int): (callable(string): (bool|int)) $fn */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes nested callable returning an unparenthesized union', function () { + $doc = '/** @param callable(int): callable(string): bool|int $fn */'; + $expected = '/** @param callable(int): (callable(string): bool|int) $fn */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes nested callable returning an array shape', function () { + $doc = '/** @param callable(int): callable(string): array{status: bool, code: int} $fn */'; + $expected = '/** @param callable(int): (callable(string): array{status: bool, code: int}) $fn */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes nested callable returning a generic list/array', function () { + $doc = '/** @param callable(int): callable(string): list $fn */'; + $expected = '/** @param callable(int): (callable(string): list) $fn */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes nested callable returning a nullable type', function () { + $doc = '/** @param callable(int): callable(string): ?string $fn */'; + $expected = '/** @param callable(int): (callable(string): ?string) $fn */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + }); + + describe('5. Tag Contexts (@return, @var, @phpstan-type, @method)', function () { + test('normalizes unparenthesized nested callable in @return tag', function () { + $doc = '/** @return callable(int): callable(string): bool */'; + $expected = '/** @return callable(int): (callable(string): bool) */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes unparenthesized nested callable in @var tag', function () { + $doc = '/** @var callable(int): callable(string): bool $pipeline */'; + $expected = '/** @var callable(int): (callable(string): bool) $pipeline */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes unparenthesized nested callable in @phpstan-type alias definition', function () { + $doc = '/** @phpstan-type CurriedPipeline callable(int): callable(string): bool */'; + $expected = '/** @phpstan-type CurriedPipeline callable(int): (callable(string): bool) */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes unparenthesized nested callable in @method tag', function () { + $doc = '/** @method callable(int): callable(string): bool createPipeline(string $prefix) */'; + $expected = '/** @method callable(int): (callable(string): bool) createPipeline(string $prefix) */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + }); + + describe('6. Higher-Order Callables (Callables Taking Callables)', function () { + test('preserves higher-order callable where argument is a callable and return is a scalar', function () { + $doc = '/** @param callable(callable(int): string): bool $cb */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($doc); + }); + + test('normalizes higher-order callable where argument is an unparenthesized curried callable', function () { + $doc = '/** @param callable(callable(int): callable(string): bool): bool $cb */'; + $expected = '/** @param callable(callable(int): (callable(string): bool)): bool $cb */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + }); + + describe('7. Omitted Return Type Combinations', function () { + test('auto-completes omitted return type on inner callable and parenthesizes', function () { + $doc = '/** @param callable(int): callable(string) $fn */'; + $expected = '/** @param callable(int): (callable(string): mixed) $fn */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('auto-completes zero-argument callables with omitted return types', function () { + $doc = '/** @param callable(): callable() $fn */'; + $expected = '/** @param callable(): (callable(): mixed) $fn */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + }); + + describe('8. Whitespace, Multi-Line & Idempotency Guarantees', function () { + test('leaves already parenthesized 2-level and 3-level callables untouched (idempotent)', function () { + $doc2 = '/** @param callable(int): (callable(string): bool) $factory */'; + expect(DocblockNormalizer::normalize($doc2))->toBe($doc2); + + $doc3 = '/** @param callable(int): (callable(string): (callable(float): int)) $chain */'; + expect(DocblockNormalizer::normalize($doc3))->toBe($doc3); + }); + + test('normalizes multiple separate nested callable parameters in the same DocBlock', function () { + $doc = <<<'DOC' +/** + * @param callable(int): callable(string): bool $a + * @param callable(float): callable(bool): int $b + */ +DOC; + $expected = <<<'DOC' +/** + * @param callable(int): (callable(string): bool) $a + * @param callable(float): (callable(bool): int) $b + */ +DOC; + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes nested callables with extra spacing around colons and keywords', function () { + $doc = '/** @param callable(int) : callable(string) : bool $fn */'; + $expected = '/** @param callable(int) : (callable(string) : bool) $fn */'; + + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('guarantees valid AST parsing through phpstan/phpdoc-parser after normalization', function () { + $rawDoc = '/** @param callable(int $a): callable(string $b): callable(float $c): int $chain */'; + $normalized = DocblockNormalizer::normalize($rawDoc); + + $phpDocNode = DocblockExtractor::parseDocString($normalized); + $paramTags = DocblockExtractor::getParamTags($phpDocNode); + + expect($paramTags)->toHaveKey('chain') + ->and((string) $paramTags['chain']->type)->toBe('callable(int $a): (callable(string $b): (callable(float $c): int))') + ; + }); + }); + }); + describe('Static Closure Normalization', function () { test('normalizes parenthesized (static Closure)(args) to static-closure(args)', function () { $doc = '/** @param (static Closure)(int): int $fn */'; diff --git a/tests/Internal/Util/ConfigTest.php b/tests/Internal/Util/ConfigTest.php index ec38887..5241ad0 100644 --- a/tests/Internal/Util/ConfigTest.php +++ b/tests/Internal/Util/ConfigTest.php @@ -4,26 +4,34 @@ use TypePHP\Internal\Util\Config; +function cleanTypePhpEnvVariables(): void +{ + $keys = [ + 'TYPEPHP_DISABLE', + 'TYPEPHP_ENABLED', + 'TYPEPHP_AUTO_BOOT', + 'TYPEPHP_ON_VIOLATION', + 'TYPEPHP_REPORT_FILE', + 'TYPEPHP_FAIL_ON_REPORT', + 'TYPEPHP_REDACT_VALUES', + ]; + + foreach ($keys as $key) { + putenv($key); + putenv("{$key}="); + unset($_ENV[$key], $_SERVER[$key]); + } + + Config::reset(); +} + describe('Config Unit Tests', function () { + beforeEach(function () { + cleanTypePhpEnvVariables(); + }); + afterEach(function () { - putenv('TYPEPHP_AUTO_BOOT='); - putenv('TYPEPHP_ON_VIOLATION='); - putenv('TYPEPHP_REPORT_FILE='); - putenv('TYPEPHP_FAIL_ON_REPORT='); - putenv('TYPEPHP_REDACT_VALUES='); - unset( - $_ENV['TYPEPHP_AUTO_BOOT'], - $_SERVER['TYPEPHP_AUTO_BOOT'], - $_ENV['TYPEPHP_ON_VIOLATION'], - $_SERVER['TYPEPHP_ON_VIOLATION'], - $_ENV['TYPEPHP_REPORT_FILE'], - $_SERVER['TYPEPHP_REPORT_FILE'], - $_ENV['TYPEPHP_FAIL_ON_REPORT'], - $_SERVER['TYPEPHP_FAIL_ON_REPORT'], - $_ENV['TYPEPHP_REDACT_VALUES'], - $_SERVER['TYPEPHP_REDACT_VALUES'] - ); - Config::reset(); + cleanTypePhpEnvVariables(); }); test('loads default configuration array', function () { @@ -122,13 +130,11 @@ }); test('supports granular and boolean magic_properties configuration', function () { - // 1. Default: write is true, read is false expect(Config::isMagicPropertyWritesEnabled())->toBeTrue() ->and(Config::isMagicPropertyReadsEnabled())->toBeFalse() ->and(Config::isMagicPropertiesEnabled())->toBeTrue() ; - // 2. Partial array override (enable reads) Config::set([ 'magic_properties' => [ 'read' => true, @@ -139,14 +145,12 @@ ->and(Config::isMagicPropertiesEnabled())->toBeTrue() ; - // 3. Boolean false override (disables both writes and reads) Config::set(['magic_properties' => false]); expect(Config::isMagicPropertyWritesEnabled())->toBeFalse() ->and(Config::isMagicPropertyReadsEnabled())->toBeFalse() ->and(Config::isMagicPropertiesEnabled())->toBeFalse() ; - // 4. Boolean true override (enables writes, keeps reads false) Config::set(['magic_properties' => true]); expect(Config::isMagicPropertyWritesEnabled())->toBeTrue() ->and(Config::isMagicPropertyReadsEnabled())->toBeFalse() @@ -186,13 +190,11 @@ ->and(Config::getArrayValidationStrategy())->toBe('full') ; - // Non-string fallback Config::set(['array_validation' => 12345]); expect(Config::getArrayValidationStrategy())->toBe('full'); }); test('syncFlags correctly validates and falls back on edge-case inputs', function () { - // Invalid ignore_trace_depth values fallback to 25 Config::set(['ignore_trace_depth' => -5]); expect(Config::getIgnoreTraceDepth())->toBe(25); @@ -202,7 +204,6 @@ Config::set(['ignore_trace_depth' => 40]); expect(Config::getIgnoreTraceDepth())->toBe(40); - // Non-array inline_vars fallback Config::set(['inline_vars' => null]); expect(Config::isInlinePropertiesEnabled())->toBeTrue(); }); @@ -219,18 +220,6 @@ ; }); - test('loads typephp.php from project root directory', function () { - $projectRoot = Config::getProjectRoot(); - $configFile = $projectRoot . '/typephp.php'; - - if (file_exists($configFile)) { - $config = Config::get(); - expect($config)->toBeArray() - ->and($config['include'])->toBeArray() - ; - } - }); - test('user include and exclude lists are replaced wholesale and do not leak default list entries', function () { Config::set([ 'include' => [ @@ -265,374 +254,157 @@ ; }); - test('resolves project root when installed in vendor using startingDir parameter', function () { - $tempBase = sys_get_temp_dir() . '/typephp_vendor_test_' . uniqid(); - $fakeVendorDir = $tempBase . '/vendor/typephp/typephp/src/Internal/Util'; - mkdir($fakeVendorDir, 0777, true); - - file_put_contents($tempBase . '/composer.json', json_encode(['name' => 'acme/consumer-app'])); - - try { - $resolved = Config::getProjectRoot($fakeVendorDir); - $realTempBase = realpath($tempBase) !== false ? realpath($tempBase) : $tempBase; - $normTempBase = rtrim(str_replace('\\', '/', (string) $realTempBase), '/'); - - expect($resolved)->toBe($normTempBase); - } finally { - @unlink($tempBase . '/composer.json'); - @rmdir($fakeVendorDir); - @rmdir($tempBase . '/vendor/typephp/typephp/src/Internal'); - @rmdir($tempBase . '/vendor/typephp/typephp/src'); - @rmdir($tempBase . '/vendor/typephp/typephp'); - @rmdir($tempBase . '/vendor/typephp'); - @rmdir($tempBase . '/vendor'); - @rmdir($tempBase); - } - }); - - test('resolves project root by climbing parent directories when not in vendor', function () { - $tempBase = sys_get_temp_dir() . '/typephp_climb_test_' . uniqid(); - $nestedSubDir = $tempBase . '/src/Modules/Commerce/Services'; - mkdir($nestedSubDir, 0777, true); - - file_put_contents($tempBase . '/composer.json', json_encode(['name' => 'acme/monorepo'])); - - try { - $resolved = Config::getProjectRoot($nestedSubDir); - $realTempBase = realpath($tempBase) !== false ? realpath($tempBase) : $tempBase; - $normTempBase = rtrim(str_replace('\\', '/', (string) $realTempBase), '/'); - - expect($resolved)->toBe($normTempBase); - } finally { - @unlink($tempBase . '/composer.json'); - @rmdir($nestedSubDir); - @rmdir($tempBase . '/src/Modules/Commerce'); - @rmdir($tempBase . '/src/Modules'); - @rmdir($tempBase . '/src'); - @rmdir($tempBase); - } - }); - - test('falls back to current directory when no composer.json or autoload.php is found after 10 parent steps', function () { - $tempBase = sys_get_temp_dir() . '/typephp_deep_empty_' . uniqid(); - $deepDir = $tempBase . '/1/2/3/4/5/6/7/8/9/10/11'; - mkdir($deepDir, 0777, true); - - try { - $resolved = Config::getProjectRoot($deepDir); - expect($resolved)->toBeString() - ->and($resolved)->not()->toBeEmpty() - ; - } finally { - for ($d = $deepDir; $d !== $tempBase; $d = \dirname($d)) { - @rmdir($d); - } - @rmdir($tempBase); - } - }); - - test('isParamsOutEnabled returns false when params_out is disabled or params is disabled', function () { - Config::set(['params' => true, 'params_out' => true]); - expect(Config::isParamsOutEnabled())->toBeTrue(); - - Config::set(['params' => true, 'params_out' => false]); - expect(Config::isParamsOutEnabled())->toBeFalse(); - - Config::set(['params' => false, 'params_out' => true]); - expect(Config::isParamsOutEnabled())->toBeFalse(); - }); - - test('set initializes cachedConfig if called when cachedConfig is null', function () { - Config::reset(); - - Config::set(['enabled' => false]); - - expect(Config::isEnabled())->toBeFalse(); - }); - - test('falls back to defaultConfig extensions when typephp.php does not exist', function () { - $tempDir = sys_get_temp_dir() . '/typephp_no_config_' . uniqid(); - mkdir($tempDir, 0777, true); - - try { - Config::reset(); - - $ref = new ReflectionClass(Config::class); - $prop = $ref->getProperty('projectRoot'); - $prop->setValue(null, $tempDir); - - $config = Config::get(); - expect($config['extensions'])->toBeEmpty(); - } finally { - Config::reset(); - @rmdir($tempDir); - } - }); - - test('hits root break when directory traversal reaches filesystem root', function () { - $root = DIRECTORY_SEPARATOR === '/' ? '/' : 'C:/'; - $result = Config::getProjectRoot($root); - - expect($result)->toBeString() - ->and($result)->not()->toBeEmpty() - ; - }); + describe('Framework & Dotenv Environment Resolution ($_ENV, $_SERVER, and getenv)', function () { + beforeEach(function () { + cleanTypePhpEnvVariables(); + }); - describe('Auto-Boot Configuration Logic', function () { - test('isAutoBootEnabled returns true by default', function () { - expect(Config::isAutoBootEnabled())->toBeTrue(); + afterEach(function () { + cleanTypePhpEnvVariables(); }); - test('isAutoBootEnabled respects runtime overrides with Config::set', function () { - Config::set(['auto_boot' => false]); - expect(Config::isAutoBootEnabled())->toBeFalse(); + test('isEnabled dynamically reflects TYPEPHP_DISABLE from $_ENV (Laravel Dotenv compatibility)', function () { + expect(Config::isEnabled())->toBeTrue(); - Config::set(['auto_boot' => true]); - expect(Config::isAutoBootEnabled())->toBeTrue(); - }); + $_ENV['TYPEPHP_DISABLE'] = 'true'; + expect(Config::isEnabled())->toBeFalse(); - test('isAutoBootEnabled respects TYPEPHP_AUTO_BOOT environment variable', function () { - putenv('TYPEPHP_AUTO_BOOT=false'); - expect(Config::isAutoBootEnabled())->toBeFalse(); + $_ENV['TYPEPHP_DISABLE'] = '1'; + expect(Config::isEnabled())->toBeFalse(); - putenv('TYPEPHP_AUTO_BOOT=true'); - expect(Config::isAutoBootEnabled())->toBeTrue(); + $_ENV['TYPEPHP_DISABLE'] = 'false'; + expect(Config::isEnabled())->toBeTrue(); - putenv('TYPEPHP_AUTO_BOOT=0'); - expect(Config::isAutoBootEnabled())->toBeFalse(); + $_ENV['TYPEPHP_DISABLE'] = '0'; + expect(Config::isEnabled())->toBeTrue(); - putenv('TYPEPHP_AUTO_BOOT=1'); - expect(Config::isAutoBootEnabled())->toBeTrue(); + unset($_ENV['TYPEPHP_DISABLE']); }); - test('isAutoBootEnabled reads extra.typephp.auto-boot from composer.json', function () { - $tempDir = sys_get_temp_dir() . '/typephp_composer_autoboot_' . uniqid(); - mkdir($tempDir, 0777, true); + test('isEnabled dynamically reflects TYPEPHP_DISABLE from $_SERVER', function () { + unset($_ENV['TYPEPHP_DISABLE']); + expect(Config::isEnabled())->toBeTrue(); - $composerJsonContent = json_encode([ - 'name' => 'test/autoboot-test', - 'extra' => [ - 'typephp' => [ - 'auto-boot' => false, - ], - ], - ]); - file_put_contents($tempDir . '/composer.json', $composerJsonContent); + $_SERVER['TYPEPHP_DISABLE'] = 'true'; + expect(Config::isEnabled())->toBeFalse(); - try { - Config::reset(); - - $ref = new ReflectionClass(Config::class); - $prop = $ref->getProperty('projectRoot'); - $prop->setValue(null, $tempDir); + $_SERVER['TYPEPHP_DISABLE'] = 'false'; + expect(Config::isEnabled())->toBeTrue(); - expect(Config::isAutoBootEnabled())->toBeFalse(); - } finally { - @unlink($tempDir . '/composer.json'); - @rmdir($tempDir); - Config::reset(); - } + unset($_SERVER['TYPEPHP_DISABLE']); }); - test('isAutoBootEnabled prioritizes typephp.php over composer.json', function () { - $tempDir = sys_get_temp_dir() . '/typephp_precedence_test_' . uniqid(); - mkdir($tempDir, 0777, true); + test('isEnabled dynamically reflects TYPEPHP_ENABLED from $_ENV and $_SERVER', function () { + expect(Config::isEnabled())->toBeTrue(); - $composerJsonContent = json_encode([ - 'name' => 'test/autoboot-test', - 'extra' => [ - 'typephp' => [ - 'auto-boot' => false, - ], - ], - ]); - file_put_contents($tempDir . '/composer.json', $composerJsonContent); + $_ENV['TYPEPHP_ENABLED'] = 'false'; + expect(Config::isEnabled())->toBeFalse(); - $typephpContent = <<<'PHP' - true, -]; -PHP; - file_put_contents($tempDir . '/typephp.php', $typephpContent); + $_ENV['TYPEPHP_ENABLED'] = '0'; + expect(Config::isEnabled())->toBeFalse(); - try { - Config::reset(); + unset($_ENV['TYPEPHP_ENABLED']); + $_SERVER['TYPEPHP_ENABLED'] = 'false'; + expect(Config::isEnabled())->toBeFalse(); - $ref = new ReflectionClass(Config::class); - $prop = $ref->getProperty('projectRoot'); - $prop->setValue(null, $tempDir); + $_SERVER['TYPEPHP_ENABLED'] = 'true'; + expect(Config::isEnabled())->toBeTrue(); - // typephp.php (true) overrides composer.json (false) - expect(Config::isAutoBootEnabled())->toBeTrue(); - } finally { - @unlink($tempDir . '/typephp.php'); - @unlink($tempDir . '/composer.json'); - @rmdir($tempDir); - Config::reset(); - } + unset($_SERVER['TYPEPHP_ENABLED']); }); - test('isAutoBootEnabled prioritizes TYPEPHP_AUTO_BOOT environment variable over all config files', function () { - $tempDir = sys_get_temp_dir() . '/typephp_env_priority_test_' . uniqid(); - mkdir($tempDir, 0777, true); + test('isAutoBootEnabled resolves values from $_ENV and $_SERVER', function () { + $_ENV['TYPEPHP_AUTO_BOOT'] = 'false'; + expect(Config::isAutoBootEnabled())->toBeFalse(); - $typephpContent = <<<'PHP' - false, -]; -PHP; - file_put_contents($tempDir . '/typephp.php', $typephpContent); + $_ENV['TYPEPHP_AUTO_BOOT'] = '0'; + expect(Config::isAutoBootEnabled())->toBeFalse(); - try { - Config::reset(); + unset($_ENV['TYPEPHP_AUTO_BOOT']); + $_SERVER['TYPEPHP_AUTO_BOOT'] = 'false'; + expect(Config::isAutoBootEnabled())->toBeFalse(); - $ref = new ReflectionClass(Config::class); - $prop = $ref->getProperty('projectRoot'); - $prop->setValue(null, $tempDir); + $_SERVER['TYPEPHP_AUTO_BOOT'] = 'true'; + expect(Config::isAutoBootEnabled())->toBeTrue(); - // Environment variable (true) overrides typephp.php (false) - putenv('TYPEPHP_AUTO_BOOT=true'); - expect(Config::isAutoBootEnabled())->toBeTrue(); - } finally { - putenv('TYPEPHP_AUTO_BOOT'); - @unlink($tempDir . '/typephp.php'); - @rmdir($tempDir); - Config::reset(); - } + unset($_SERVER['TYPEPHP_AUTO_BOOT']); }); - }); - describe('Violation Handling & Reporting Configuration', function () { - test('on_violation defaults to throw and normalizes warn/report values', function () { - expect(Config::getOnViolation())->toBe('throw') - ->and(Config::isReportMode())->toBeFalse() - ->and(Config::isWarnMode())->toBeFalse() - ; - - Config::set(['on_violation' => 'report']); + test('getOnViolation resolves strategy from $_ENV and $_SERVER', function () { + $_ENV['TYPEPHP_ON_VIOLATION'] = 'report'; expect(Config::getOnViolation())->toBe('report') ->and(Config::isReportMode())->toBeTrue() ->and(Config::isWarnMode())->toBeFalse() ; - Config::set(['on_violation' => 'warn']); + $_ENV['TYPEPHP_ON_VIOLATION'] = 'warn'; expect(Config::getOnViolation())->toBe('warn') ->and(Config::isReportMode())->toBeFalse() ->and(Config::isWarnMode())->toBeTrue() ; - Config::set(['on_violation' => 'warning']); + unset($_ENV['TYPEPHP_ON_VIOLATION']); + $_SERVER['TYPEPHP_ON_VIOLATION'] = 'warning'; expect(Config::getOnViolation())->toBe('warn') ->and(Config::isWarnMode())->toBeTrue() ; - }); - - test('report_file defaults to null and respects custom paths', function () { - expect(Config::getReportFile())->toBeNull(); - - Config::set(['report_file' => 'var/typephp-report.json']); - expect(Config::getReportFile())->toBe('var/typephp-report.json'); - Config::set(['report_file' => ' ']); - expect(Config::getReportFile())->toBeNull(); + unset($_SERVER['TYPEPHP_ON_VIOLATION']); }); - test('fail_on_report defaults to false and respects boolean overrides', function () { - expect(Config::isFailOnReportEnabled())->toBeFalse(); + test('getReportFile resolves custom file path from $_ENV and $_SERVER', function () { + $_ENV['TYPEPHP_REPORT_FILE'] = 'storage/reports/laravel-audit.json'; + expect(Config::getReportFile())->toBe('storage/reports/laravel-audit.json'); - Config::set(['fail_on_report' => true]); - expect(Config::isFailOnReportEnabled())->toBeTrue(); - }); + unset($_ENV['TYPEPHP_REPORT_FILE']); + $_SERVER['TYPEPHP_REPORT_FILE'] = 'var/ci-report.json'; + expect(Config::getReportFile())->toBe('var/ci-report.json'); - test('respects TYPEPHP_ON_VIOLATION, TYPEPHP_REPORT_FILE, and TYPEPHP_FAIL_ON_REPORT environment variables', function () { - putenv('TYPEPHP_ON_VIOLATION=report'); - putenv('TYPEPHP_REPORT_FILE=var/ci-report.json'); - putenv('TYPEPHP_FAIL_ON_REPORT=true'); - - expect(Config::getOnViolation())->toBe('report') - ->and(Config::isReportMode())->toBeTrue() - ->and(Config::getReportFile())->toBe('var/ci-report.json') - ->and(Config::isFailOnReportEnabled())->toBeTrue() - ; - - putenv('TYPEPHP_ON_VIOLATION=warning'); - expect(Config::getOnViolation())->toBe('warn') - ->and(Config::isWarnMode())->toBeTrue() - ; + unset($_SERVER['TYPEPHP_REPORT_FILE']); }); - test('reads reporting options from composer.json extra section', function () { - $tempDir = sys_get_temp_dir() . '/typephp_composer_report_' . uniqid(); - mkdir($tempDir, 0777, true); - - $composerJsonContent = json_encode([ - 'name' => 'test/report-config', - 'extra' => [ - 'typephp' => [ - 'on-violation' => 'report', - 'report-file' => 'var/composer-report.json', - 'fail-on-report' => true, - ], - ], - ]); - file_put_contents($tempDir . '/composer.json', $composerJsonContent); - - try { - Config::reset(); + test('isFailOnReportEnabled resolves from $_ENV and $_SERVER', function () { + $_ENV['TYPEPHP_FAIL_ON_REPORT'] = 'true'; + expect(Config::isFailOnReportEnabled())->toBeTrue(); - $ref = new ReflectionClass(Config::class); - $prop = $ref->getProperty('projectRoot'); - $prop->setValue(null, $tempDir); + $_ENV['TYPEPHP_FAIL_ON_REPORT'] = '1'; + expect(Config::isFailOnReportEnabled())->toBeTrue(); - expect(Config::getOnViolation())->toBe('report') - ->and(Config::getReportFile())->toBe('var/composer-report.json') - ->and(Config::isFailOnReportEnabled())->toBeTrue() - ; - } finally { - @unlink($tempDir . '/composer.json'); - @rmdir($tempDir); - Config::reset(); - } - }); - }); + unset($_ENV['TYPEPHP_FAIL_ON_REPORT']); + $_SERVER['TYPEPHP_FAIL_ON_REPORT'] = 'false'; + expect(Config::isFailOnReportEnabled())->toBeFalse(); - describe('Global Value Redaction Configuration (redact_values)', function () { - test('isRedactValuesEnabled returns false by default', function () { - expect(Config::isRedactValuesEnabled())->toBeFalse(); + unset($_SERVER['TYPEPHP_FAIL_ON_REPORT']); }); - test('isRedactValuesEnabled respects runtime overrides with Config::set', function () { - Config::set(['redact_values' => true]); + test('isRedactValuesEnabled resolves from $_ENV and $_SERVER', function () { + $_ENV['TYPEPHP_REDACT_VALUES'] = 'true'; expect(Config::isRedactValuesEnabled())->toBeTrue(); - Config::set(['redact_values' => false]); - expect(Config::isRedactValuesEnabled())->toBeFalse(); - }); - - test('isRedactValuesEnabled respects TYPEPHP_REDACT_VALUES environment variable', function () { - putenv('TYPEPHP_REDACT_VALUES=true'); + $_ENV['TYPEPHP_REDACT_VALUES'] = '1'; expect(Config::isRedactValuesEnabled())->toBeTrue(); - putenv('TYPEPHP_REDACT_VALUES=false'); + unset($_ENV['TYPEPHP_REDACT_VALUES']); + $_SERVER['TYPEPHP_REDACT_VALUES'] = 'false'; expect(Config::isRedactValuesEnabled())->toBeFalse(); - putenv('TYPEPHP_REDACT_VALUES=1'); - expect(Config::isRedactValuesEnabled())->toBeTrue(); - - putenv('TYPEPHP_REDACT_VALUES=0'); - expect(Config::isRedactValuesEnabled())->toBeFalse(); + unset($_SERVER['TYPEPHP_REDACT_VALUES']); }); + }); - test('isRedactValuesEnabled reads extra.typephp.redact-values from composer.json', function () { - $tempDir = sys_get_temp_dir() . '/typephp_composer_redact_' . uniqid(); + describe('Composer.json Extra Configuration Integration', function () { + test('reads reporting and autoboot options from composer.json extra section', function () { + $tempDir = sys_get_temp_dir() . '/typephp_composer_full_' . uniqid(); mkdir($tempDir, 0777, true); $composerJsonContent = json_encode([ - 'name' => 'test/redact-test', + 'name' => 'test/full-composer-config', 'extra' => [ 'typephp' => [ + 'auto-boot' => false, + 'on-violation' => 'report', + 'report-file' => 'var/composer-report.json', + 'fail-on-report' => true, 'redact-values' => true, ], ], @@ -646,7 +418,12 @@ $prop = $ref->getProperty('projectRoot'); $prop->setValue(null, $tempDir); - expect(Config::isRedactValuesEnabled())->toBeTrue(); + expect(Config::isAutoBootEnabled())->toBeFalse() + ->and(Config::getOnViolation())->toBe('report') + ->and(Config::getReportFile())->toBe('var/composer-report.json') + ->and(Config::isFailOnReportEnabled())->toBeTrue() + ->and(Config::isRedactValuesEnabled())->toBeTrue() + ; } finally { @unlink($tempDir . '/composer.json'); @rmdir($tempDir); diff --git a/tests/Pest.php b/tests/Pest.php index c9008db..72e2980 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -7,6 +7,10 @@ function resetTypePHPTestEnvironment(): void { + putenv('TYPEPHP_DISABLE'); + putenv('TYPEPHP_DISABLE='); + putenv('TYPEPHP_ENABLED'); + putenv('TYPEPHP_ENABLED='); putenv('TYPEPHP_AUTO_BOOT'); putenv('TYPEPHP_AUTO_BOOT='); putenv('TYPEPHP_ON_VIOLATION'); @@ -19,6 +23,10 @@ function resetTypePHPTestEnvironment(): void putenv('TYPEPHP_REDACT_VALUES='); unset( + $_ENV['TYPEPHP_DISABLE'], + $_SERVER['TYPEPHP_DISABLE'], + $_ENV['TYPEPHP_ENABLED'], + $_SERVER['TYPEPHP_ENABLED'], $_ENV['TYPEPHP_AUTO_BOOT'], $_SERVER['TYPEPHP_AUTO_BOOT'], $_ENV['TYPEPHP_ON_VIOLATION'],