Skip to content

Commit

Permalink
Allow specifying a custom default template type
Browse files Browse the repository at this point in the history
  • Loading branch information
danog committed Jan 19, 2024
1 parent b7a18bd commit 14bab7b
Show file tree
Hide file tree
Showing 6 changed files with 128 additions and 12 deletions.
48 changes: 48 additions & 0 deletions docs/annotating_code/templated_annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,54 @@ function array_combine(array $arr, array $arr2) {}
- `@template` tag order matters for class docblocks, as they dictate the order in which those generic parameters are referenced in docblocks.
- The names of your templated types (e.g. `TKey`, `TValue`) don't matter outside the scope of the class or function in which they're declared.

## Default template values

By default, if a class template parameter is not initialized in the constructor or by a `@var` annotation, Psalm will initialize it to `mixed`.

However, a custom default initial type can also by provided using the following syntax:

```php
<?php

/**
* Template T can have any type (`mixed`), but if it's not specified, it will default to `never`.
*
* @template T as mixed = never
*/
class MyContainer {
/** @var list<T> */
private array $values = [];

/** @param T $value */
public function addValue($value): void {
$this->values []= $value;
}

/** @return list<T> */
public function getValue(): array {
return $this->values;
}
}

$t1 = new MyContainer;

/** @psalm-trace $t1 */; // MyContainer<never>

// InvalidArgument: Argument 1 of MyContainer::addValue expects never, but 123 provided
$t1->addValue(123);


/** @var MyContainer<int> Always specify template parameters! */
$t2 = new MyContainer;

/** @psalm-trace $t2 */; // MyContainer<int>

// OK!
$t2->addValue(123);
```

This can be often useful, like in the above example, to always force specification of a template type when constructing generic objects by specifying `never` as default type: `never` is the [bottom type](https://psalm.dev/docs/annotating_code/type_syntax/top_bottom_types/#never), and thus all usages of methods relying on an *uninitialized* template type will use `never` and will emit Psalm issues, essentially warning the user to explicitly specify a template parameter when constructing the class.

## @param class-string&lt;T&gt;

Psalm also allows you to parameterize class types
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -490,8 +490,8 @@ private static function analyzeNamedConstructor(

$generic_param_types = null;

if ($storage->template_types) {
foreach ($storage->template_types as $template_name => $base_type) {
if ($storage->default_template_types) {

Check failure on line 493 in src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php

View workflow job for this annotation

GitHub Actions / build

RiskyTruthyFalsyComparison

src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php:493:17: RiskyTruthyFalsyComparison: Operand of type array<string, non-empty-array<string, Psalm\Type\Union>>|null contains type array<string, non-empty-array<string, Psalm\Type\Union>>, which can be falsy and truthy. This can cause possibly unexpected behavior. Use strict comparison instead. (see https://psalm.dev/356)
foreach ($storage->default_template_types as $template_name => $base_type) {
if (isset($template_result->lower_bounds[$template_name][$fq_class_name])) {
$generic_param_type = TemplateStandinTypeReplacer::getMostSpecificTypeFromBounds(
$template_result->lower_bounds[$template_name][$fq_class_name],
Expand All @@ -515,11 +515,7 @@ private static function analyzeNamedConstructor(
),
);
} else {
if ($fq_class_name === 'SplObjectStorage') {
$generic_param_type = Type::getNever();
} else {
$generic_param_type = array_values($base_type)[0];
}
$generic_param_type = array_values($base_type)[0];
}

$generic_param_types[] = $generic_param_type->setProperties([
Expand Down Expand Up @@ -550,13 +546,13 @@ private static function analyzeNamedConstructor(
),
$statements_analyzer->getSuppressedIssues(),
);
} elseif ($storage->template_types) {
} elseif ($storage->default_template_types) {

Check failure on line 549 in src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php

View workflow job for this annotation

GitHub Actions / build

RiskyTruthyFalsyComparison

src/Psalm/Internal/Analyzer/Statements/Expression/Call/NewAnalyzer.php:549:19: RiskyTruthyFalsyComparison: Operand of type array<string, non-empty-array<string, Psalm\Type\Union>>|null contains type array<string, non-empty-array<string, Psalm\Type\Union>>, which can be falsy and truthy. This can cause possibly unexpected behavior. Use strict comparison instead. (see https://psalm.dev/356)
$result_atomic_type = new TGenericObject(
$fq_class_name,
array_values(
array_map(
static fn($map) => reset($map),
$storage->template_types,
$storage->default_template_types,
),
),
false,
Expand Down
41 changes: 40 additions & 1 deletion src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -431,8 +431,16 @@ public function start(PhpParser\Node\Stmt\ClassLike $node): ?bool
if ($template_map[1] !== null && $template_map[2] !== null) {
if (trim($template_map[2])) {
$type_string = $template_map[2];
$default_type_string = null;
try {
$type_string = CommentAnalyzer::splitDocLine($type_string)[0];
$type_string_split = CommentAnalyzer::splitDocLine($type_string);
if (isset($type_string_split[1])
&& isset($type_string_split[2])
&& $type_string_split[1] === '='
) {
$default_type_string = $type_string_split[2];
}
$type_string = $type_string_split[0];
} catch (DocblockParseException $e) {
throw new DocblockParseException(
$type_string . ' is not a valid type: ' . $e->getMessage(),
Expand Down Expand Up @@ -463,6 +471,37 @@ public function start(PhpParser\Node\Stmt\ClassLike $node): ?bool
$storage->template_types[$template_name] = [
$fq_classlike_name => $template_type,
];

if ($default_type_string !== null) {
$default_type_string = CommentAnalyzer::sanitizeDocblockType($default_type_string);
try {
$default_template_type = TypeParser::parseTokens(
TypeTokenizer::getFullyQualifiedTokens(
$default_type_string,
$this->aliases,
$storage->template_types,
$this->type_aliases,
),
null,
$storage->template_types,
$this->type_aliases,
);
} catch (TypeParseTreeException $e) {
$storage->docblock_issues[] = new InvalidDocblock(
$e->getMessage() . ' in docblock for ' . $fq_classlike_name,
$name_location ?? $class_location,
);

continue;
}

$storage->default_template_types[$template_name] = [
$fq_classlike_name => $default_template_type,
];
} else {
$storage->default_template_types[$template_name] =
$storage->template_types[$template_name];
}
} else {
$storage->docblock_issues[] = new InvalidDocblock(
'Template missing as type',
Expand Down
11 changes: 11 additions & 0 deletions src/Psalm/Storage/ClassLikeStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,17 @@ final class ClassLikeStorage implements HasAttributesInterface
*/
public $template_types;

/**
* An array holding the class template default types.
*
* The name of the template is the first key. The nested array is keyed by the defining class
* (i.e. the same as the class name). This allows operations with the same-named template defined
* across multiple classes to not run into trouble.
*
* @var array<string, non-empty-array<string, Union>>|null
*/
public $default_template_types;

/**
* @var array<int, bool>|null
*/
Expand Down
4 changes: 2 additions & 2 deletions stubs/SPL.phpstub
Original file line number Diff line number Diff line change
Expand Up @@ -735,8 +735,8 @@ class SplPriorityQueue implements Iterator, Countable {
* cases involving the need to uniquely identify objects.
* @link https://php.net/manual/en/class.splobjectstorage.php
*
* @template TObject as object
* @template TArrayValue
* @template TObject as object = never
* @template TArrayValue as mixed = never
* @template-implements ArrayAccess<TObject, TArrayValue>
* @template-implements Iterator<int, TObject>
*/
Expand Down
22 changes: 22 additions & 0 deletions tests/Template/ClassTemplateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4209,6 +4209,28 @@ public function aggregate(...$values): null|int|float
'ignored_issues' => [],
'php_version' => '8.0',
],
'defaultTemplateType' => [
'code' => '<?php
/**
* @template T as mixed = never
*/
class a {
public function __construct() {}
}
$a = new a;',
'assertions' => [
'$a===' => 'a<never>',
],
],
'initializeSplObjectStorage' => [
'code' => '<?php
$a = new SplObjectStorage();
',
'assertions' => [
'$a===' => 'SplObjectStorage<never, never>',
],
],
];
}

Expand Down

0 comments on commit 14bab7b

Please sign in to comment.