diff --git a/docs/advanced/how-it-works.md b/docs/advanced/how-it-works.md index f1b7928..ec33b90 100644 --- a/docs/advanced/how-it-works.md +++ b/docs/advanced/how-it-works.md @@ -93,11 +93,13 @@ TypePHP solves this using `TypePHPPrinter` and regex post-processing. Injected g ### Disk Caching -Once transformed, TypePHP saves the resulting code to disk in `sys_get_temp_dir() . '/typephp-cache/'`. On all subsequent requests: +Once transformed, TypePHP saves the resulting code to disk in your configured `cache_dir` (which defaults to `sys_get_temp_dir() . '/typephp-cache/'`). On all subsequent requests: * AST parsing runs **0 times**. * PHP's **OPCache** compiles the cached file once into bytecode in RAM. * Stream file reads execute natively at C-level speed inside Zend Engine. +*(TypePHP's stream wrapper automatically detects and skips intercepting files inside your configured `cache_dir` to prevent infinite loops and double-transformation overhead).* + --- ## Typed Arrays and Array Shapes @@ -182,10 +184,6 @@ For function-level templates (`@template T`), TypePHP pushes a temporary call fr --- -Here is the updated, brief **Validation Error Messages and Trace Attribution** section for `docs/architecture/how-it-works.md`: - ---- - ## Validation Error Messages and Trace Attribution When a type contract fails, TypePHP constructs informative error messages through a 3-tier pipeline: diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 5e1f5f8..5885c84 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -54,11 +54,17 @@ return [ /* |-------------------------------------------------------------------------- - | Enable Caching - |-------------------------------------------------------------------------- - | Pre-transforms and caches PHP files on disk for maximum speed. + | Enable Caching & Cache Directory + |-------------------------------------------------------------------------- + | Pre-transforms and caches PHP files on disk for OPcache optimization. + | + | 'cache_dir' determines where these files are stored. By default (null), + | it uses your system's temp directory. You can change this to a path + | inside your project (e.g., __DIR__ . '/storage/framework/typephp'). + | TypePHP automatically protects this directory from being double-transformed. */ 'cache' => true, + 'cache_dir' => null, /* |-------------------------------------------------------------------------- @@ -90,7 +96,7 @@ return [ | Included Paths & Whitelisting |-------------------------------------------------------------------------- | Globs or specific file paths that should be intercepted and type-checked. - | Note: you can just specify "*" glob pattern to include all files including in the root folder. + | Note: you can just specify "**" glob pattern to include all files including in the root folder. */ 'include' => [ 'src/**', @@ -129,34 +135,42 @@ Key options explained: | **`'magic_properties'`** | `true` | Enforces class-level `@property`, `@property-read`, and `@property-write` annotations on dynamic assignments (`__set`). | | **`'magic_methods'`** | `true` | Enforces class-level `@method` annotations on dynamic method calls (`__call` / `__callStatic`). | | **`'respect_ignore_tags'`** | `true` | Respects `@typephp-ignore` and `@typephp-ignore-file` tags. Set to `false` in CI/CD to force audit checks. | -| **`'cache'`** | `true` | Pre-transforms and caches PHP files on disk (`typephp-cache/`) for OPcache optimization. | +| **`'cache'`** | `true` | Pre-transforms and caches PHP files on disk. | +| **`'cache_dir'`** | `null` | Custom path to store cached files. Defaults to system temporary directory (`sys_get_temp_dir() . '/typephp-cache/'`). | --- ## Inline Variable Categories Reference (`inline_vars`) -How each `inline_vars` toggle maps to PHPDoc type annotations: +*(... rest of the file remains exactly the same ...)* +``` -| Config Option | Covered PHPDoc Types | Examples | -| :--- | :--- | :--- | -| **`'scalars'`** | Primitive & Refined Scalars | `int`, `string`, `bool`, `positive-int`, `non-empty-string`, `truthy` | -| **`'objects'`** | Class Instances & Bare Class References | `User`, `stdClass`, `class-string`, `interface-string`, `enum-string` | -| **`'generics'`** | Template & Bound Types | `Collection`, `Producer`, `class-string` | -| **`'arrays'`** | All Arrays, Shapes, & Lists | `array{id: int}`, `int[]`, `User[]`, `list`, `array` | -| **`'callables'`** | Callables & Closures | `callable`, `Closure`, `callable(int): string`, `static-closure` | -| **`'properties'`** | Class Property Writes | `$this->id = 1`, `UserProfile::$username = 'Alice'` | +--- + +### 2. `docs/advanced/how-it-works.md` + +*(Find the "Zero Line-Drift Formatting and Caching" section and update the "Disk Caching" part to this:)* -### Important Notes on `inline_vars` Behavior +```markdown +### Disk Caching -* **Inner Structural Types Are Always Validated:** Disabling `'scalars' => false` only turns off standalone scalar assignments (such as `/** @var positive-int $x */`). If `'arrays'` or `'generics'` is enabled, TypePHP **will still validate inner scalar constraints** inside array shapes (`array{id: positive-int}`), lists (`list`), or generic containers (`Collection`) to maintain structural type integrity. -* **Active Generic Instance Prebinding:** Enabling `'generics' => true` allows inline `@var` annotations on object instantiations (such as `/** @var Collection $users */ $users = new Collection();`) to **actively prebind generic template parameters (`T = User`)** directly to that object instance in `WeakMap` memory. Every subsequent method call on that instance (`$users->add()`, `$users->get()`) will enforce `T = User`! +Once transformed, TypePHP saves the resulting code to disk in your configured `cache_dir` (which defaults to `sys_get_temp_dir() . '/typephp-cache/'`). On all subsequent requests: +* AST parsing runs **0 times**. +* PHP's **OPCache** compiles the cached file once into bytecode in RAM. +* Stream file reads execute natively at C-level speed inside Zend Engine. + +*(TypePHP's stream wrapper automatically detects and skips intercepting files inside your configured `cache_dir` to prevent infinite loops and double-transformation overhead).* +``` --- -## Pattern Specificity Rules +### 3. `docs/troubleshooting.md` + +*(Find the "How do I know if TypePHP is actively transforming a file?" question and update the answer:)* -If a file matches both an `include` rule and an `exclude` rule, TypePHP compares pattern lengths: +```markdown -* **Specific Whitelist Wins:** `'vendor/my-org/package/**'` (length 25) takes precedence over `'vendor/**'` (length 8). -* **Single File Override:** `'src/LegacyFile.php'` (length 22) takes precedence over `'src/**'` (length 6). -* **Tie-Breaker:** If pattern lengths are equal, `exclude` takes precedence to ensure application safety. +``` + +--- +All docs are updated! What's our next target? Should we start refactoring validators, expanding the Extension System, or write a quick web-framework mock test? \ No newline at end of file diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1be29dc..9956196 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -22,7 +22,15 @@ If TypePHP is not enforcing contracts on a specific file or method, check the fo You can verify that a file is being intercepted and transformed in two ways: 1. **Intentionally Trigger an Error:** Pass an invalid argument (such as a negative integer to a `positive-int` parameter). If a `TypePHP\Exception\TypeError` is thrown, TypePHP is active. -2. **Inspect the Cache Directory:** Look inside your system temporary directory (`sys_get_temp_dir() . '/typephp-cache/'`). You will see transformed PHP files containing injected `RuntimeTypeChecker` calls. +2. **Inspect the Cache Directory:** Look inside your configured `cache_dir` (if undefined, this defaults to your system temporary directory: `sys_get_temp_dir() . '/typephp-cache/'`). You will see transformed PHP files containing injected `RuntimeTypeChecker` calls. + +--- + +### Why are files inside my custom `cache_dir` not being intercepted? + +If you configured a custom `cache_dir` inside your project directory (e.g., `__DIR__ . '/storage/typephp'`) and set your include paths to `['**']`, you might wonder why the cache files aren't being transformed. + +**This is a built-in safety mechanism.** TypePHP automatically detects your `cache_dir` and unconditionally excludes it from its internal `StreamWrapper` and `FileFilter`. This prevents catastrophic infinite loops and double-parsing overhead that would occur if TypePHP tried to intercept and transform its own cached files. --- diff --git a/src/Command/ConfigInitCommand.php b/src/Command/ConfigInitCommand.php index 46c4779..bcf4630 100644 --- a/src/Command/ConfigInitCommand.php +++ b/src/Command/ConfigInitCommand.php @@ -87,12 +87,18 @@ private static function getTemplate(): string /* |-------------------------------------------------------------------------- - | Enable Caching + | Enable Caching & Cache Directory |-------------------------------------------------------------------------- | When enabled, transformed PHP files are cached on disk for speed. | Set to false to run AST transformations purely in RAM (php://memory). + | + | 'cache_dir' determines where these files are stored. By default (null), + | it uses your system's temp directory. You can change this to a path + | inside your project, e.g., __DIR__ . '/storage/framework/typephp'. + | TypePHP will automatically protect this directory from being re-transformed. */ 'cache' => true, + 'cache_dir' => null, /* |-------------------------------------------------------------------------- diff --git a/src/Contract/FileFilter.php b/src/Contract/FileFilter.php index 8f9da7b..a1b6d54 100644 --- a/src/Contract/FileFilter.php +++ b/src/Contract/FileFilter.php @@ -4,6 +4,7 @@ namespace TypePHP\Contract; +use TypePHP\Internal\CacheManager; use TypePHP\Internal\Config; /** @@ -28,6 +29,11 @@ public static function isFileExcluded(string|false|null $fileName): bool return true; } + $normalizedCacheDir = rtrim(str_replace('\\', '/', CacheManager::getCacheDir()), '/') . '/'; + if (str_starts_with($normalizedPath, $normalizedCacheDir)) { + return true; + } + $config = Config::get(); /** @var array $includes */ $includes = \is_array($config['include'] ?? null) ? $config['include'] : ['**']; diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index c82a024..9a69056 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -117,6 +117,8 @@ public static function transformSource(string $source, string $filePath = ''): s return $source; } + $originalLineCount = substr_count($source, "\n"); + $parser = (new ParserFactory())->createForNewestSupportedVersion(); try { @@ -150,9 +152,26 @@ public static function transformSource(string $source, string $filePath = ''): s $printer = new TypePHPPrinter(); $transformed = $printer->printFormatPreserving($newStmts, $oldStmts, $oldTokens); - // Critical: Remove the newline and indentation preceding any injected statement. - $transformed = preg_replace('/[ \t]*\r?\n[ \t]*\/\*__TYPEPHP_INJECTED__\*\//', ' /*__TYPEPHP_INJECTED__*/', $transformed) ?? $transformed; - $transformed = str_replace('/*__TYPEPHP_INJECTED__*/', '', $transformed); + $transformed = preg_replace('/[ \t]*\r?\n[ \t]*\/\*__TYPEPHP_INJECTED_START__\*\//', ' /*__TYPEPHP_INJECTED_START__*/', $transformed) ?? $transformed; + + $transformedLineCount = substr_count($transformed, "\n"); + $drift = $transformedLineCount - $originalLineCount; + + if ($drift > 0) { + $transformed = preg_replace('/[ \t]*\r?\n[ \t]*\{[ \t]*\/\*__TYPEPHP_INJECTED_START__\*\//', ' { /*__TYPEPHP_INJECTED_START__*/', $transformed, $drift, $count1) ?? $transformed; + $drift -= $count1; + } + + if ($drift > 0) { + $transformed = preg_replace('/\/\*__TYPEPHP_INJECTED_END__\*\/[ \t]*\r?\n[ \t]*\}/', '/*__TYPEPHP_INJECTED_END__*/ }', $transformed, $drift, $count2) ?? $transformed; + $drift -= $count2; + } + + if ($drift > 0) { + $transformed = preg_replace('/\/\*__TYPEPHP_INJECTED_END__\*\/[ \t]*\r?\n[ \t]*/', '/*__TYPEPHP_INJECTED_END__*/ ', $transformed, $drift) ?? $transformed; + } + + $transformed = str_replace(['/*__TYPEPHP_INJECTED_START__*/', '/*__TYPEPHP_INJECTED_END__*/'], '', $transformed); return $transformed; } @@ -486,8 +505,8 @@ private static function isReadOnlyCall(): bool } /** - * Determines whether a target PHP file path should be intercepted using Pattern Specificity. - */ + * Determines whether a target PHP file path should be intercepted using Pattern Specificity. + */ private static function isApplicationFile(string $path, string|false $resolvedPath): bool { if (! (bool) (Config::get()['enabled'] ?? true)) { @@ -500,6 +519,7 @@ private static function isApplicationFile(string $path, string|false $resolvedPa $normalizedPath = str_replace('\\', '/', $resolvedPath); + // Prevent parsing TypePHP's own source code $parentDir = realpath(__DIR__ . '/..'); $libSrcDir = $parentDir !== false ? str_replace('\\', '/', $parentDir) : ''; @@ -507,6 +527,12 @@ private static function isApplicationFile(string $path, string|false $resolvedPa return false; } + // Unconditionally prevent double-parsing cached files! + $normalizedCacheDir = rtrim(str_replace('\\', '/', self::$cacheDir), '/') . '/'; + if (str_starts_with($normalizedPath, $normalizedCacheDir)) { + return false; + } + $longestIncludeMatch = 0; foreach (self::$includeRawPatterns as $pattern => $regex) { if (preg_match($regex, $normalizedPath) === 1) { diff --git a/src/Internal/TypePHPPrinter.php b/src/Internal/TypePHPPrinter.php index 4fa423b..d69dbb0 100644 --- a/src/Internal/TypePHPPrinter.php +++ b/src/Internal/TypePHPPrinter.php @@ -18,7 +18,7 @@ final class TypePHPPrinter extends Standard { /** * Overrides base node printing to intercept injected statements, squash their - * formatting, and tag them with a unique marker for post-processing. + * formatting, and tag them with unique markers for post-processing. */ protected function p( Node $node, @@ -31,7 +31,7 @@ protected function p( if ($node instanceof Node\Stmt && $node->getAttribute('typephp_injected') === true) { $output = preg_replace('/\s+/', ' ', trim($output)) ?? $output; - return '/*__TYPEPHP_INJECTED__*/' . $output; + return '/*__TYPEPHP_INJECTED_START__*/' . $output . '/*__TYPEPHP_INJECTED_END__*/'; } return $output; diff --git a/src/Internal/Visitor/PropertyHookInjector.php b/src/Internal/Visitor/PropertyHookInjector.php index 0b843c6..6919fd3 100644 --- a/src/Internal/Visitor/PropertyHookInjector.php +++ b/src/Internal/Visitor/PropertyHookInjector.php @@ -43,21 +43,49 @@ public static function process(Node\Stmt\Property $node): void : 'value'; $checkCall = NodeBuilder::createPropertyCheckCall(new Node\Expr\Variable($paramName), new Node\Expr\Variable('this'), $propertyName); - $paramCheckStmt = new Node\Stmt\Expression( - new Node\Expr\Assign( - new Node\Expr\Variable($paramName), - NodeBuilder::createTernaryThrowExpr($checkCall) - ) - ); - $paramCheckStmt->setAttribute('typephp_injected', true); if (\is_array($hook->body)) { + $paramCheckStmt = new Node\Stmt\Expression( + new Node\Expr\Assign( + new Node\Expr\Variable($paramName), + NodeBuilder::createTernaryThrowExpr($checkCall) + ) + ); + $paramCheckStmt->setAttribute('typephp_injected', true); array_unshift($hook->body, $paramCheckStmt); } elseif ($hook->body instanceof Node\Expr) { - $hook->body = [ - $paramCheckStmt, - new Node\Stmt\Expression($hook->body), - ]; + // Bypass php-parser formatting bugs by keeping short hooks as Expressions + $hook->body = new Node\Expr\Ternary( + new Node\Expr\Instanceof_( + new Node\Expr\Assign( + new Node\Expr\Variable('__typephpVal'), + $checkCall + ), + new Node\Name('\TypePHP\Internal\ErrorMessage') + ), + new Node\Expr\Throw_( + new Node\Expr\StaticCall( + new Node\Name('\TypePHP\Internal\ErrorFactory'), + 'prepareException', + [ + new Node\Arg( + new Node\Expr\New_( + new Node\Name('\TypePHP\Exception\TypeError'), + [ + new Node\Arg( + new Node\Expr\MethodCall( + new Node\Expr\Variable('__typephpVal'), + 'getMessage' + ) + ), + ] + ) + ), + ] + ) + ), + $hook->body // False branch evaluates the original assignment + ); } } } diff --git a/tests/Contract/FileFilterTest.php b/tests/Contract/FileFilterTest.php index d410b60..15c178d 100644 --- a/tests/Contract/FileFilterTest.php +++ b/tests/Contract/FileFilterTest.php @@ -137,4 +137,25 @@ Config::reset(); }); + + test('unconditionally excludes the configured cache directory even if include pattern is **', function () { + $customCacheDir = getcwd() . '/storage/typephp-cache'; + + Config::set([ + 'cache_dir' => $customCacheDir, + 'include' => [ + '**', + ], + 'exclude' => [], + ]); + + $cachedFilePath = str_replace('\\', '/', $customCacheDir . '/v0.1_hash123.php'); + $normalFilePath = str_replace('\\', '/', getcwd() . '/app/Models/User.php'); + + expect(FileFilter::isFileExcluded($cachedFilePath))->toBeTrue() + ->and(FileFilter::isFileExcluded($normalFilePath))->toBeFalse() + ; + + Config::reset(); + }); }); diff --git a/tests/Unit/LineNumberPreservationTest.php b/tests/Unit/LineNumberPreservationTest.php index 3677ae4..7e497de 100644 --- a/tests/Unit/LineNumberPreservationTest.php +++ b/tests/Unit/LineNumberPreservationTest.php @@ -22,7 +22,7 @@ function number(int $number): int number(-5); PHP; - $transformed = StreamWrapper::transformSource($source, 'test4.php'); + $transformed = StreamWrapper::transformSource($source, 'test_params.php'); $origLines = explode("\n", str_replace("\r\n", "\n", $source)); $transLines = explode("\n", str_replace("\r\n", "\n", $transformed)); @@ -56,7 +56,7 @@ public function __construct(public array $numbers) new Numbers(['a', 'b', 'c', 1]); PHP; - $transformed = StreamWrapper::transformSource($source, 'test8.php'); + $transformed = StreamWrapper::transformSource($source, 'test_cpm.php'); $origLines = explode("\n", str_replace("\r\n", "\n", $source)); $transLines = explode("\n", str_replace("\r\n", "\n", $transformed)); @@ -68,4 +68,69 @@ public function __construct(public array $numbers) expect($transCallLine)->toBe($origCallLine); }); + + test('transforms single-line empty methods and constructors without shifting line numbers', function () { + $source = <<<'PHP' +toBe(\count($origLines)); + + $origCallLine = array_search('$obj = new SingleLineBlocks();', array_map('trim', $origLines), true); + $transCallLine = array_search('$obj = new SingleLineBlocks();', array_map('trim', $transLines), true); + + expect($transCallLine)->toBe($origCallLine); + }); + + test('transforms generic single-line constructors perfectly (Edge Case Reproduction)', function () { + $source = <<<'PHP' +toBe(\count($origLines)); + + $origCallLine = array_search("\$p = new Producer('test');", array_map('trim', $origLines), true); + $transCallLine = array_search("\$p = new Producer('test');", array_map('trim', $transLines), true); + + expect($transCallLine)->toBe($origCallLine); + }); }); diff --git a/tests/Visitor/PropertyHookInjectorTest.php b/tests/Visitor/PropertyHookInjectorTest.php index d9fb991..ee17d07 100644 --- a/tests/Visitor/PropertyHookInjectorTest.php +++ b/tests/Visitor/PropertyHookInjectorTest.php @@ -10,7 +10,7 @@ use TypePHP\Internal\Visitor\PropertyHookInjector; describe('PropertyHookInjector Unit Tests', function () { - test('wraps short get property hooks (get => $expr)', function () { + test('wraps short get property hooks (get => $expr) in ternary', function () { $hook = new Node\PropertyHook( name: 'get', body: new Node\Scalar\String_('invalid') @@ -27,7 +27,28 @@ expect($prop->hooks[0]->body)->toBeInstanceOf(Node\Expr\Ternary::class); }); - test('wraps set property hooks and injects paramCheckStmt with typephp_injected attribute', function () { + test('wraps short set property hooks (set => $expr) in ternary to avoid parser bugs', function () { + $hook = new Node\PropertyHook( + name: 'set', + body: new Node\Expr\Assign( + new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), 'title'), + new Node\Expr\Variable('value') + ) + ); + + $prop = new Node\Stmt\Property( + flags: Node\Stmt\Class_::MODIFIER_PUBLIC, + props: [new Node\PropertyItem('title')], + hooks: [$hook] + ); + + PropertyHookInjector::process($prop); + + // The body should remain an expression (Ternary), not an array of statements + expect($prop->hooks[0]->body)->toBeInstanceOf(Node\Expr\Ternary::class); + }); + + test('injects paramCheckStmt into block set property hooks', function () { $hook = new Node\PropertyHook( name: 'set', body: [ diff --git a/typephp.php b/typephp.php index 5ecb606..f5d8a62 100644 --- a/typephp.php +++ b/typephp.php @@ -42,14 +42,20 @@ */ 'respect_ignore_tags' => true, - /* + /* |-------------------------------------------------------------------------- - | Enable Caching + | Enable Caching & Cache Directory |-------------------------------------------------------------------------- | When enabled, transformed PHP files are cached on disk for speed. | Set to false to run AST transformations purely in RAM (php://memory). + | + | 'cache_dir' determines where these files are stored. By default (null), + | it uses your system's temp directory. You can change this to a path + | inside your project, e.g., __DIR__ . '/storage/framework/typephp'. + | TypePHP will automatically protect this directory from being re-transformed. */ 'cache' => true, + 'cache_dir' => null, /* |-------------------------------------------------------------------------- @@ -99,7 +105,7 @@ | You can use "*" glob to match any file. */ 'include' => [ - 'src/**', + '*', 'app/**', 'internals/**', 'tests/**',