Skip to content

Commit 96e4804

Browse files
authored
Internal improvements 4 (#24)
* Enhance caching configuration documentation and add cache directory option * Implement cache directory exclusion in FileFilter and update tests * Update documentation and improve caching configuration details
1 parent 7a0aed3 commit 96e4804

12 files changed

Lines changed: 253 additions & 54 deletions

File tree

docs/advanced/how-it-works.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,13 @@ TypePHP solves this using `TypePHPPrinter` and regex post-processing. Injected g
9393

9494
### Disk Caching
9595

96-
Once transformed, TypePHP saves the resulting code to disk in `sys_get_temp_dir() . '/typephp-cache/'`. On all subsequent requests:
96+
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:
9797
* AST parsing runs **0 times**.
9898
* PHP's **OPCache** compiles the cached file once into bytecode in RAM.
9999
* Stream file reads execute natively at C-level speed inside Zend Engine.
100100

101+
*(TypePHP's stream wrapper automatically detects and skips intercepting files inside your configured `cache_dir` to prevent infinite loops and double-transformation overhead).*
102+
101103
---
102104

103105
## Typed Arrays and Array Shapes
@@ -182,10 +184,6 @@ For function-level templates (`@template T`), TypePHP pushes a temporary call fr
182184

183185
---
184186

185-
Here is the updated, brief **Validation Error Messages and Trace Attribution** section for `docs/architecture/how-it-works.md`:
186-
187-
---
188-
189187
## Validation Error Messages and Trace Attribution
190188

191189
When a type contract fails, TypePHP constructs informative error messages through a 3-tier pipeline:

docs/getting-started/configuration.md

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,17 @@ return [
5454

5555
/*
5656
|--------------------------------------------------------------------------
57-
| Enable Caching
58-
|--------------------------------------------------------------------------
59-
| Pre-transforms and caches PHP files on disk for maximum speed.
57+
| Enable Caching & Cache Directory
58+
|--------------------------------------------------------------------------
59+
| Pre-transforms and caches PHP files on disk for OPcache optimization.
60+
|
61+
| 'cache_dir' determines where these files are stored. By default (null),
62+
| it uses your system's temp directory. You can change this to a path
63+
| inside your project (e.g., __DIR__ . '/storage/framework/typephp').
64+
| TypePHP automatically protects this directory from being double-transformed.
6065
*/
6166
'cache' => true,
67+
'cache_dir' => null,
6268

6369
/*
6470
|--------------------------------------------------------------------------
@@ -90,7 +96,7 @@ return [
9096
| Included Paths & Whitelisting
9197
|--------------------------------------------------------------------------
9298
| Globs or specific file paths that should be intercepted and type-checked.
93-
| Note: you can just specify "*" glob pattern to include all files including in the root folder.
99+
| Note: you can just specify "**" glob pattern to include all files including in the root folder.
94100
*/
95101
'include' => [
96102
'src/**',
@@ -129,34 +135,42 @@ Key options explained:
129135
| **`'magic_properties'`** | `true` | Enforces class-level `@property`, `@property-read`, and `@property-write` annotations on dynamic assignments (`__set`). |
130136
| **`'magic_methods'`** | `true` | Enforces class-level `@method` annotations on dynamic method calls (`__call` / `__callStatic`). |
131137
| **`'respect_ignore_tags'`** | `true` | Respects `@typephp-ignore` and `@typephp-ignore-file` tags. Set to `false` in CI/CD to force audit checks. |
132-
| **`'cache'`** | `true` | Pre-transforms and caches PHP files on disk (`typephp-cache/`) for OPcache optimization. |
138+
| **`'cache'`** | `true` | Pre-transforms and caches PHP files on disk. |
139+
| **`'cache_dir'`** | `null` | Custom path to store cached files. Defaults to system temporary directory (`sys_get_temp_dir() . '/typephp-cache/'`). |
133140

134141
---
135142

136143
## Inline Variable Categories Reference (`inline_vars`)
137144

138-
How each `inline_vars` toggle maps to PHPDoc type annotations:
145+
*(... rest of the file remains exactly the same ...)*
146+
```
139147
140-
| Config Option | Covered PHPDoc Types | Examples |
141-
| :--- | :--- | :--- |
142-
| **`'scalars'`** | Primitive & Refined Scalars | `int`, `string`, `bool`, `positive-int`, `non-empty-string`, `truthy` |
143-
| **`'objects'`** | Class Instances & Bare Class References | `User`, `stdClass`, `class-string`, `interface-string`, `enum-string` |
144-
| **`'generics'`** | Template & Bound Types | `Collection<User>`, `Producer<T>`, `class-string<T>` |
145-
| **`'arrays'`** | All Arrays, Shapes, & Lists | `array{id: int}`, `int[]`, `User[]`, `list<string>`, `array<string, int>` |
146-
| **`'callables'`** | Callables & Closures | `callable`, `Closure`, `callable(int): string`, `static-closure` |
147-
| **`'properties'`** | Class Property Writes | `$this->id = 1`, `UserProfile::$username = 'Alice'` |
148+
---
149+
150+
### 2. `docs/advanced/how-it-works.md`
151+
152+
*(Find the "Zero Line-Drift Formatting and Caching" section and update the "Disk Caching" part to this:)*
148153
149-
### Important Notes on `inline_vars` Behavior
154+
```markdown
155+
### Disk Caching
150156
151-
* **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<positive-int>`), or generic containers (`Collection<positive-int>`) to maintain structural type integrity.
152-
* **Active Generic Instance Prebinding:** Enabling `'generics' => true` allows inline `@var` annotations on object instantiations (such as `/** @var Collection<User> $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`!
157+
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:
158+
* AST parsing runs **0 times**.
159+
* PHP's **OPCache** compiles the cached file once into bytecode in RAM.
160+
* Stream file reads execute natively at C-level speed inside Zend Engine.
161+
162+
*(TypePHP's stream wrapper automatically detects and skips intercepting files inside your configured `cache_dir` to prevent infinite loops and double-transformation overhead).*
163+
```
153164

154165
---
155166

156-
## Pattern Specificity Rules
167+
### 3. `docs/troubleshooting.md`
168+
169+
*(Find the "How do I know if TypePHP is actively transforming a file?" question and update the answer:)*
157170

158-
If a file matches both an `include` rule and an `exclude` rule, TypePHP compares pattern lengths:
171+
```markdown
159172

160-
* **Specific Whitelist Wins:** `'vendor/my-org/package/**'` (length 25) takes precedence over `'vendor/**'` (length 8).
161-
* **Single File Override:** `'src/LegacyFile.php'` (length 22) takes precedence over `'src/**'` (length 6).
162-
* **Tie-Breaker:** If pattern lengths are equal, `exclude` takes precedence to ensure application safety.
173+
```
174+
175+
---
176+
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?

docs/troubleshooting.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,15 @@ If TypePHP is not enforcing contracts on a specific file or method, check the fo
2222
You can verify that a file is being intercepted and transformed in two ways:
2323

2424
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.
25-
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.
25+
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.
26+
27+
---
28+
29+
### Why are files inside my custom `cache_dir` not being intercepted?
30+
31+
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.
32+
33+
**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.
2634

2735
---
2836

src/Command/ConfigInitCommand.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,18 @@ private static function getTemplate(): string
8787
8888
/*
8989
|--------------------------------------------------------------------------
90-
| Enable Caching
90+
| Enable Caching & Cache Directory
9191
|--------------------------------------------------------------------------
9292
| When enabled, transformed PHP files are cached on disk for speed.
9393
| Set to false to run AST transformations purely in RAM (php://memory).
94+
|
95+
| 'cache_dir' determines where these files are stored. By default (null),
96+
| it uses your system's temp directory. You can change this to a path
97+
| inside your project, e.g., __DIR__ . '/storage/framework/typephp'.
98+
| TypePHP will automatically protect this directory from being re-transformed.
9499
*/
95100
'cache' => true,
101+
'cache_dir' => null,
96102
97103
/*
98104
|--------------------------------------------------------------------------

src/Contract/FileFilter.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace TypePHP\Contract;
66

7+
use TypePHP\Internal\CacheManager;
78
use TypePHP\Internal\Config;
89

910
/**
@@ -28,6 +29,11 @@ public static function isFileExcluded(string|false|null $fileName): bool
2829
return true;
2930
}
3031

32+
$normalizedCacheDir = rtrim(str_replace('\\', '/', CacheManager::getCacheDir()), '/') . '/';
33+
if (str_starts_with($normalizedPath, $normalizedCacheDir)) {
34+
return true;
35+
}
36+
3137
$config = Config::get();
3238
/** @var array<mixed> $includes */
3339
$includes = \is_array($config['include'] ?? null) ? $config['include'] : ['**'];

src/Internal/StreamWrapper.php

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ public static function transformSource(string $source, string $filePath = ''): s
117117
return $source;
118118
}
119119

120+
$originalLineCount = substr_count($source, "\n");
121+
120122
$parser = (new ParserFactory())->createForNewestSupportedVersion();
121123

122124
try {
@@ -150,9 +152,26 @@ public static function transformSource(string $source, string $filePath = ''): s
150152
$printer = new TypePHPPrinter();
151153
$transformed = $printer->printFormatPreserving($newStmts, $oldStmts, $oldTokens);
152154

153-
// Critical: Remove the newline and indentation preceding any injected statement.
154-
$transformed = preg_replace('/[ \t]*\r?\n[ \t]*\/\*__TYPEPHP_INJECTED__\*\//', ' /*__TYPEPHP_INJECTED__*/', $transformed) ?? $transformed;
155-
$transformed = str_replace('/*__TYPEPHP_INJECTED__*/', '', $transformed);
155+
$transformed = preg_replace('/[ \t]*\r?\n[ \t]*\/\*__TYPEPHP_INJECTED_START__\*\//', ' /*__TYPEPHP_INJECTED_START__*/', $transformed) ?? $transformed;
156+
157+
$transformedLineCount = substr_count($transformed, "\n");
158+
$drift = $transformedLineCount - $originalLineCount;
159+
160+
if ($drift > 0) {
161+
$transformed = preg_replace('/[ \t]*\r?\n[ \t]*\{[ \t]*\/\*__TYPEPHP_INJECTED_START__\*\//', ' { /*__TYPEPHP_INJECTED_START__*/', $transformed, $drift, $count1) ?? $transformed;
162+
$drift -= $count1;
163+
}
164+
165+
if ($drift > 0) {
166+
$transformed = preg_replace('/\/\*__TYPEPHP_INJECTED_END__\*\/[ \t]*\r?\n[ \t]*\}/', '/*__TYPEPHP_INJECTED_END__*/ }', $transformed, $drift, $count2) ?? $transformed;
167+
$drift -= $count2;
168+
}
169+
170+
if ($drift > 0) {
171+
$transformed = preg_replace('/\/\*__TYPEPHP_INJECTED_END__\*\/[ \t]*\r?\n[ \t]*/', '/*__TYPEPHP_INJECTED_END__*/ ', $transformed, $drift) ?? $transformed;
172+
}
173+
174+
$transformed = str_replace(['/*__TYPEPHP_INJECTED_START__*/', '/*__TYPEPHP_INJECTED_END__*/'], '', $transformed);
156175

157176
return $transformed;
158177
}
@@ -486,8 +505,8 @@ private static function isReadOnlyCall(): bool
486505
}
487506

488507
/**
489-
* Determines whether a target PHP file path should be intercepted using Pattern Specificity.
490-
*/
508+
* Determines whether a target PHP file path should be intercepted using Pattern Specificity.
509+
*/
491510
private static function isApplicationFile(string $path, string|false $resolvedPath): bool
492511
{
493512
if (! (bool) (Config::get()['enabled'] ?? true)) {
@@ -500,13 +519,20 @@ private static function isApplicationFile(string $path, string|false $resolvedPa
500519

501520
$normalizedPath = str_replace('\\', '/', $resolvedPath);
502521

522+
// Prevent parsing TypePHP's own source code
503523
$parentDir = realpath(__DIR__ . '/..');
504524
$libSrcDir = $parentDir !== false ? str_replace('\\', '/', $parentDir) : '';
505525

506526
if ($libSrcDir !== '' && str_starts_with($normalizedPath, $libSrcDir)) {
507527
return false;
508528
}
509529

530+
// Unconditionally prevent double-parsing cached files!
531+
$normalizedCacheDir = rtrim(str_replace('\\', '/', self::$cacheDir), '/') . '/';
532+
if (str_starts_with($normalizedPath, $normalizedCacheDir)) {
533+
return false;
534+
}
535+
510536
$longestIncludeMatch = 0;
511537
foreach (self::$includeRawPatterns as $pattern => $regex) {
512538
if (preg_match($regex, $normalizedPath) === 1) {

src/Internal/TypePHPPrinter.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ final class TypePHPPrinter extends Standard
1818
{
1919
/**
2020
* Overrides base node printing to intercept injected statements, squash their
21-
* formatting, and tag them with a unique marker for post-processing.
21+
* formatting, and tag them with unique markers for post-processing.
2222
*/
2323
protected function p(
2424
Node $node,
@@ -31,7 +31,7 @@ protected function p(
3131
if ($node instanceof Node\Stmt && $node->getAttribute('typephp_injected') === true) {
3232
$output = preg_replace('/\s+/', ' ', trim($output)) ?? $output;
3333

34-
return '/*__TYPEPHP_INJECTED__*/' . $output;
34+
return '/*__TYPEPHP_INJECTED_START__*/' . $output . '/*__TYPEPHP_INJECTED_END__*/';
3535
}
3636

3737
return $output;

src/Internal/Visitor/PropertyHookInjector.php

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,49 @@ public static function process(Node\Stmt\Property $node): void
4343
: 'value';
4444

4545
$checkCall = NodeBuilder::createPropertyCheckCall(new Node\Expr\Variable($paramName), new Node\Expr\Variable('this'), $propertyName);
46-
$paramCheckStmt = new Node\Stmt\Expression(
47-
new Node\Expr\Assign(
48-
new Node\Expr\Variable($paramName),
49-
NodeBuilder::createTernaryThrowExpr($checkCall)
50-
)
51-
);
52-
$paramCheckStmt->setAttribute('typephp_injected', true);
5346

5447
if (\is_array($hook->body)) {
48+
$paramCheckStmt = new Node\Stmt\Expression(
49+
new Node\Expr\Assign(
50+
new Node\Expr\Variable($paramName),
51+
NodeBuilder::createTernaryThrowExpr($checkCall)
52+
)
53+
);
54+
$paramCheckStmt->setAttribute('typephp_injected', true);
5555
array_unshift($hook->body, $paramCheckStmt);
5656
} elseif ($hook->body instanceof Node\Expr) {
57-
$hook->body = [
58-
$paramCheckStmt,
59-
new Node\Stmt\Expression($hook->body),
60-
];
57+
// Bypass php-parser formatting bugs by keeping short hooks as Expressions
58+
$hook->body = new Node\Expr\Ternary(
59+
new Node\Expr\Instanceof_(
60+
new Node\Expr\Assign(
61+
new Node\Expr\Variable('__typephpVal'),
62+
$checkCall
63+
),
64+
new Node\Name('\TypePHP\Internal\ErrorMessage')
65+
),
66+
new Node\Expr\Throw_(
67+
new Node\Expr\StaticCall(
68+
new Node\Name('\TypePHP\Internal\ErrorFactory'),
69+
'prepareException',
70+
[
71+
new Node\Arg(
72+
new Node\Expr\New_(
73+
new Node\Name('\TypePHP\Exception\TypeError'),
74+
[
75+
new Node\Arg(
76+
new Node\Expr\MethodCall(
77+
new Node\Expr\Variable('__typephpVal'),
78+
'getMessage'
79+
)
80+
),
81+
]
82+
)
83+
),
84+
]
85+
)
86+
),
87+
$hook->body // False branch evaluates the original assignment
88+
);
6189
}
6290
}
6391
}

tests/Contract/FileFilterTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,25 @@
137137

138138
Config::reset();
139139
});
140+
141+
test('unconditionally excludes the configured cache directory even if include pattern is **', function () {
142+
$customCacheDir = getcwd() . '/storage/typephp-cache';
143+
144+
Config::set([
145+
'cache_dir' => $customCacheDir,
146+
'include' => [
147+
'**',
148+
],
149+
'exclude' => [],
150+
]);
151+
152+
$cachedFilePath = str_replace('\\', '/', $customCacheDir . '/v0.1_hash123.php');
153+
$normalFilePath = str_replace('\\', '/', getcwd() . '/app/Models/User.php');
154+
155+
expect(FileFilter::isFileExcluded($cachedFilePath))->toBeTrue()
156+
->and(FileFilter::isFileExcluded($normalFilePath))->toBeFalse()
157+
;
158+
159+
Config::reset();
160+
});
140161
});

0 commit comments

Comments
 (0)