Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions src/Internal/Ast/ContractVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
use TypePHP\Internal\Docblock\DocblockExtractor;
use TypePHP\Internal\Util\Config;

/**
* @internal AST Node Visitor that injects contract checks, scope tracking, property hook validation, and parameter/return wrappers into functions and methods.
Expand Down Expand Up @@ -71,6 +72,20 @@ public function enterNode(Node $node): ?array
$this->scopeManager->pushScope();
}

if ($node instanceof Node\Expr\Assign || $node instanceof Node\Expr\AssignOp) {
$this->markWriteContext($node->var);
} elseif ($node instanceof Node\Expr\PreInc || $node instanceof Node\Expr\PostInc || $node instanceof Node\Expr\PreDec || $node instanceof Node\Expr\PostDec) {
$this->markWriteContext($node->var);
} elseif ($node instanceof Node\Stmt\Unset_) {
foreach ($node->vars as $v) {
$this->markWriteContext($v);
}
}

if ($node instanceof Node\Stmt\Class_) {
$this->processClassPropertyDefaults($node);
}

if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) {
$classContext = $this->classStack !== [] ? end($this->classStack) : null;
FunctionContractInjector::inject($node, $classContext);
Expand Down Expand Up @@ -135,13 +150,130 @@ public function leaveNode(Node $node): ?Node
}
}

if ($node instanceof Node\Expr\StaticPropertyFetch && $node->name instanceof Node\VarLikeIdentifier) {
$propName = $node->name->toString();

if ($node->getAttribute('typephp_checked') !== true && $node->getAttribute('typephp_write_context') !== true) {
$node->setAttribute('typephp_checked', true);
$classArg = $node->class instanceof Node\Name
? new Node\Expr\ClassConstFetch($node->class, 'class')
: $node->class;

return new Node\Expr\StaticCall(
new Node\Name\FullyQualified('TypePHP\Internal\RuntimeTypeChecker'),
'checkStaticProperty',
[
new Node\Arg($classArg),
new Node\Arg(new Node\Scalar\String_($propName)),
new Node\Arg($node),
new Node\Arg(new Node\Scalar\MagicConst\File()),
new Node\Arg(new Node\Scalar\LNumber($node->getStartLine())),
]
);
}
}

if ($this->isScopeBoundary($node)) {
$this->scopeManager->popScope();
}

return null;
}

private function markWriteContext(Node $node): void
{
$node->setAttribute('typephp_write_context', true);
if ($node instanceof Node\Expr\ArrayDimFetch || $node instanceof Node\Expr\PropertyFetch) {
$this->markWriteContext($node->var);
}
}

private function processClassPropertyDefaults(Node\Stmt\Class_ $node): void
{
if (! Config::isInlinePropertiesEnabled()) {
return;
}

$defaultProps = [];
$hasConstructor = false;

foreach ($node->stmts as $stmt) {
if ($stmt instanceof Node\Stmt\ClassMethod && strtolower($stmt->name->toString()) === '__construct') {
$hasConstructor = true;
} elseif ($stmt instanceof Node\Stmt\Property && ! $stmt->isStatic()) {
$doc = $stmt->getDocComment();
if ($doc !== null && str_contains($doc->getText(), '@var') && ! str_contains($doc->getText(), '@typephp-ignore')) {
foreach ($stmt->props as $p) {
$isExplicitNull = $p->default instanceof Node\Expr\ConstFetch && strtolower($p->default->name->toString()) === 'null';
if ($p->default !== null && ! $isExplicitNull) {
$defaultProps[] = [
'name' => $p->name->toString(),
'line' => $p->getStartLine(),
];
}
}
}
}
}

if ($defaultProps === []) {
return;
}

if (! $hasConstructor) {
$ctorStmts = [];
if ($node->extends !== null) {
$ctorStmts[] = new Node\Stmt\If_(
new Node\Expr\FuncCall(new Node\Name('method_exists'), [
new Node\Arg(new Node\Expr\ClassConstFetch(new Node\Name('parent'), 'class')),
new Node\Arg(new Node\Scalar\String_('__construct')),
]),
['stmts' => [
new Node\Stmt\Expression(new Node\Expr\StaticCall(new Node\Name('parent'), '__construct', [new Node\Arg(new Node\Expr\Variable('_typephp_ctor_args'), false, true)])),
]]
);
}

foreach ($defaultProps as $dp) {
$checkCall = NodeBuilder::createPropertyCheckCall(
new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), $dp['name']),
new Node\Expr\Variable('this'),
$dp['name']
);
$stmt = new Node\Stmt\Expression(NodeBuilder::createTernaryThrowExpr($checkCall, $dp['line']));
$stmt->setAttribute('typephp_injected', true);
$ctorStmts[] = $stmt;
}

$ctor = new Node\Stmt\ClassMethod('__construct', [
'flags' => Node\Stmt\Class_::MODIFIER_PUBLIC,
'params' => [new Node\Param(new Node\Expr\Variable('_typephp_ctor_args'), null, null, false, true)],
'stmts' => $ctorStmts,
]);
$ctor->setAttribute('typephp_injected', true);
$node->stmts[] = $ctor;
} else {
foreach ($node->stmts as $stmt) {
if ($stmt instanceof Node\Stmt\ClassMethod && strtolower($stmt->name->toString()) === '__construct') {
$injected = [];
foreach ($defaultProps as $dp) {
$checkCall = NodeBuilder::createPropertyCheckCall(
new Node\Expr\PropertyFetch(new Node\Expr\Variable('this'), $dp['name']),
new Node\Expr\Variable('this'),
$dp['name']
);
$checkStmt = new Node\Stmt\Expression(NodeBuilder::createTernaryThrowExpr($checkCall, $dp['line']));
$checkStmt->setAttribute('typephp_injected', true);
$injected[] = $checkStmt;
}
$stmt->stmts = [...$injected, ...($stmt->stmts ?? [])];

break;
}
}
}
}

private function trackDeclarationEntry(Node $node): void
{
if ($node instanceof Node\Stmt\Namespace_) {
Expand Down
4 changes: 1 addition & 3 deletions src/Internal/Ast/PropertyHookInjector.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,7 @@ private static function shouldSkipInjection(Node\Stmt\Property $node): bool
return false;
}

$shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true);

return $shouldRespectIgnore && (str_contains($doc->getText(), '@typephp-ignore') || str_contains($doc->getText(), '@typephp-disable'));
return Config::isRespectIgnoreTagsEnabled() && (str_contains($doc->getText(), '@typephp-ignore') || str_contains($doc->getText(), '@typephp-disable'));
}

private static function processGetHook(Node\PropertyHook $hook, string $propertyName): void
Expand Down
71 changes: 29 additions & 42 deletions src/Internal/Checker/InlineChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,7 @@ public static function checkVariable(
?string $caller = null,
mixed $thisOrClass = null
): mixed {
$rawConfig = Config::get()['inline_vars'] ?? [];
/** @var array<string, bool> $config */
$config = \is_array($rawConfig) ? $rawConfig : [];

if (! self::hasActiveInlineChecks($config)) {
if (! Config::hasActiveInlineChecks()) {
return $value;
}

Expand All @@ -150,7 +146,7 @@ public static function checkVariable(
$typeNode = self::resolveCallerContext($typeNode, $caller, $thisOrClass);
}

if (! self::shouldValidateType($typeNode, $config)) {
if (! self::shouldValidateType($typeNode)) {
return $value;
}

Expand All @@ -166,8 +162,20 @@ public static function checkVariable(
return CallableWrapper::wrapTypeNode($typeNode, $value, $cbPrefix, $registry);
}

$checkGenerics = (bool) ($config['generics'] ?? true);
if ($typeNode instanceof GenericTypeNode && $checkGenerics && \is_object($value)) {
if ($typeNode instanceof GenericTypeNode) {
$baseName = strtolower($typeNode->type->name);
$isCollection = isset(self::ARRAY_TYPES[$baseName]);

if (! $isCollection) {
if (! Config::isInlineGenericsEnabled() && Config::isInlineObjectsEnabled()) {
$typeNode = $typeNode->type;
} elseif (! Config::isInlineGenericsEnabled() && ! Config::isInlineObjectsEnabled()) {
return $value;
}
}
}

if ($typeNode instanceof GenericTypeNode && Config::isInlineGenericsEnabled() && \is_object($value)) {
$err = TemplateManager::bindInstanceFromNode($value, $typeNode, $context);
if ($err !== null) {
return $err;
Expand Down Expand Up @@ -225,11 +233,7 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string
return $value;
}

$rawConfig = Config::get()['inline_vars'] ?? [];
/** @var array<string, bool> $config */
$config = \is_array($rawConfig) ? $rawConfig : [];

if (! ($config['properties'] ?? true)) {
if (! Config::isInlinePropertiesEnabled()) {
return $value;
}

Expand All @@ -240,7 +244,7 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string
return $value;
}

if (! self::shouldValidateType($typeNode, $config)) {
if (! self::shouldValidateType($typeNode)) {
return $value;
}

Expand All @@ -260,20 +264,6 @@ public static function checkProperty(mixed $value, mixed $objectOrClass, string
return $value;
}

/**
* Checks if at least one inline variable category is active.
*
* @param array<string, bool> $config
*/
private static function hasActiveInlineChecks(array $config): bool
{
return (bool) ($config['generics'] ?? true)
|| (bool) ($config['callables'] ?? true)
|| (bool) ($config['scalars'] ?? false)
|| (bool) ($config['arrays'] ?? false)
|| (bool) ($config['objects'] ?? false);
}

/**
* Resolves caller class or function context and applies templates & type aliases to the AST.
*/
Expand Down Expand Up @@ -550,15 +540,12 @@ private static function getTypeParserComponents(): array
return [$typeParser, $lexer];
}

/**
* @param array<string, bool> $config
*/
private static function shouldValidateType(TypeNode $node, array $config): bool
private static function shouldValidateType(TypeNode $node): bool
{
$checkArrays = (bool) ($config['arrays'] ?? false);
$checkArrays = Config::isInlineArraysEnabled();

if ($node instanceof CallableTypeNode) {
return (bool) ($config['callables'] ?? true);
return Config::isInlineCallablesEnabled();
}

if ($node instanceof ObjectShapeNode || $node instanceof ArrayShapeNode || $node instanceof ArrayTypeNode) {
Expand All @@ -573,18 +560,18 @@ private static function shouldValidateType(TypeNode $node, array $config): bool
}

if ($lower === 'callable') {
return (bool) ($config['callables'] ?? true);
return Config::isInlineCallablesEnabled();
}

if (isset(self::ARRAY_TYPES[$lower])) {
return $checkArrays;
}

if (isset(self::SCALAR_TYPES[$lower])) {
return (bool) ($config['scalars'] ?? false);
return Config::isInlineScalarsEnabled();
}

return (bool) ($config['objects'] ?? false);
return Config::isInlineObjectsEnabled();
}

if ($node instanceof GenericTypeNode) {
Expand All @@ -593,27 +580,27 @@ private static function shouldValidateType(TypeNode $node, array $config): bool
return $checkArrays;
}

if ((bool) ($config['generics'] ?? true)) {
if (Config::isInlineGenericsEnabled()) {
return true;
}

return (bool) ($config['objects'] ?? false);
return Config::isInlineObjectsEnabled();
}

if ($node instanceof NullableTypeNode) {
return self::shouldValidateType($node->type, $config);
return self::shouldValidateType($node->type);
}

if ($node instanceof UnionTypeNode || $node instanceof IntersectionTypeNode) {
foreach ($node->types as $t) {
if (self::shouldValidateType($t, $config)) {
if (self::shouldValidateType($t)) {
return true;
}
}

return false;
}

return (bool) ($config['scalars'] ?? false);
return Config::isInlineScalarsEnabled();
}
}
11 changes: 11 additions & 0 deletions src/Internal/Cli/ConfigInitCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ private static function getTemplate(): string
*/
'respect_ignore_tags' => true,

/*
|--------------------------------------------------------------------------
| Ignore Tag Stack Trace Depth
|--------------------------------------------------------------------------
| Controls how many stack frames above a failing type check TypePHP will
| inspect to find an enclosing @typephp-ignore or @typephp-disable tag.
| Default is 25 frames. Increase this if your application or test suite
| uses deep call stacks (e.g. pipelines, middlewares, or nested callers).
*/
'ignore_trace_depth' => 25,

/*
|--------------------------------------------------------------------------
| Array Validation Strategy
Expand Down
5 changes: 3 additions & 2 deletions src/Internal/Diagnostic/ErrorFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ final class ErrorFactory
'iterator $',
'return iterator',
'generator sent value',
'property ',
];

/**
Expand All @@ -47,9 +48,9 @@ public static function createError(string $message): ErrorMessage
* Prepares a TypeError exception before throwing by filtering internal library frames
* and repointing the exception to the actual application caller location.
*/
public static function prepareException(TypeError $e, ?int $line = null): TypeError
public static function prepareException(TypeError $e, ?int $line = null, ?string $file = null): TypeError
{
$targetFile = null;
$targetFile = $file;
$targetLine = $line;

$message = $e->getMessage();
Expand Down
7 changes: 3 additions & 4 deletions src/Internal/Docblock/DocblockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use TypePHP\Internal\Resolver\SpecialTypeResolver;
use TypePHP\Internal\Util\Config;
use TypePHP\Internal\Util\FileFilter;
use TypePHP\Internal\Util\IgnoreManager;
use TypePHP\Internal\Util\StubManager;
use TypePHP\Internal\Validator\TypeValidatorRegistry;

Expand Down Expand Up @@ -472,7 +473,7 @@ public static function parseProperty(string $className, string $propertyName): ?
$typeNode = null;
$isMagicProperty = false;

if ($doc === false && (bool) (Config::get()['magic_properties'] ?? true)) {
if ($doc === false && Config::isMagicPropertiesEnabled()) {
$magicResolved = self::findMagicPropertyDoc($refClass, $propertyName);
if ($magicResolved !== null) {
$doc = $magicResolved['doc'];
Expand Down Expand Up @@ -764,9 +765,7 @@ private static function extractRawParamName(object $paramNode): string

private static function shouldIgnoreDoc(string $doc): bool
{
$shouldRespectIgnore = (bool) (Config::get()['respect_ignore_tags'] ?? true);

return $shouldRespectIgnore && (str_contains($doc, '@typephp-ignore') || str_contains($doc, '@typephp-disable'));
return Config::isRespectIgnoreTagsEnabled() && IgnoreManager::hasIgnoreDocTag($doc);
}

/**
Expand Down
Loading
Loading