Skip to content

Commit cbc386e

Browse files
committed
Implement method template support
1 parent 2498044 commit cbc386e

6 files changed

Lines changed: 1213 additions & 0 deletions

File tree

example.php

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,3 +850,119 @@ function test() {
850850
$this->cached->getEmail(); // $cached has type T → User
851851
}
852852
}
853+
854+
855+
// ═══════════════════════════════════════════════════════════════════════════
856+
// Method-Level @template Support
857+
// ═══════════════════════════════════════════════════════════════════════════
858+
//
859+
// PHPantomLSP resolves method-level @template parameters from call-site
860+
// arguments. The canonical pattern:
861+
//
862+
// @template T
863+
// @param class-string<T> $class
864+
// @return T
865+
//
866+
// When you call such a method with `SomeClass::class`, the return type
867+
// is resolved to `SomeClass` — enabling full completion on the result.
868+
869+
// ── Service Locator / DI Container Pattern ──────────────────────────────────
870+
871+
class ServiceLocator {
872+
/**
873+
* @template T
874+
* @param class-string<T> $id
875+
* @return T
876+
*/
877+
public function get(string $id): object
878+
{
879+
// ...
880+
}
881+
}
882+
883+
$locator = new ServiceLocator();
884+
$locator->get(User::class)->getEmail(); // Resolved: User
885+
$locator->get(UserProfile::class)->setBio('hi'); // Resolved: UserProfile
886+
887+
888+
// ── Entity Manager / Repository Pattern ─────────────────────────────────────
889+
890+
class EntityManager {
891+
/**
892+
* @template TEntity
893+
* @param class-string<TEntity> $entityClass
894+
* @return TEntity
895+
*/
896+
public function find(string $entityClass): object
897+
{
898+
// ...
899+
}
900+
901+
/**
902+
* @template TEntity
903+
* @param class-string<TEntity> $entityClass
904+
* @return TEntity|null
905+
*/
906+
public function findOrNull(string $entityClass): ?object
907+
{
908+
// ...
909+
}
910+
}
911+
912+
$em = new EntityManager();
913+
$em->find(User::class)->getName(); // Resolved: User
914+
$em->find(AdminUser::class)->grantPermission(''); // Resolved: AdminUser
915+
$em->findOrNull(Response::class)?->getBody(); // Resolved: ?Response
916+
917+
// Inline chain (no intermediate variable):
918+
$em->find(UserProfile::class)->getDisplayName(); // Resolved: UserProfile
919+
920+
921+
// ── Static Method with @template ────────────────────────────────────────────
922+
923+
class Factory {
924+
/**
925+
* @template T
926+
* @param class-string<T> $class
927+
* @return T
928+
*/
929+
public static function create(string $class): object
930+
{
931+
return new $class();
932+
}
933+
}
934+
935+
Factory::create(User::class)->getEmail(); // Resolved: User
936+
937+
938+
// ── Standalone Function with @template ──────────────────────────────────────
939+
940+
/**
941+
* @template T
942+
* @param class-string<T> $class
943+
* @return T
944+
*/
945+
function resolve(string $class): object
946+
{
947+
return new $class();
948+
}
949+
950+
resolve(AdminUser::class)->grantPermission('x'); // Resolved: AdminUser
951+
$user = resolve(User::class);
952+
$user->getEmail(); // Resolved: User
953+
954+
955+
// ── @template with $this-> context ──────────────────────────────────────────
956+
957+
class AbstractRepository2 {
958+
/**
959+
* @template T
960+
* @param class-string<T> $class
961+
* @return T
962+
*/
963+
public function load(string $class): object { return new $class(); }
964+
965+
public function demo(): void {
966+
$this->load(User::class)->getEmail(); // Resolved: User
967+
}
968+
}

src/docblock/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ pub use tags::{
4141
extract_var_type, extract_var_type_with_name, find_inline_var_docblock,
4242
find_iterable_raw_type_in_source, find_var_raw_type_in_source, get_docblock_text_for_node,
4343
has_deprecated_tag, resolve_effective_type, should_override_type,
44+
synthesize_template_conditional,
4445
};
4546

4647
// Conditional return types

src/docblock/tags.rs

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ use mago_syntax::ast::*;
1414

1515
use crate::types::{AssertionKind, MethodInfo, ParameterInfo, TypeAssertion, Visibility};
1616

17+
use crate::types::{ConditionalReturnType, ParamCondition};
18+
1719
use super::types::{clean_type, is_scalar, split_type_token, strip_nullable};
1820

1921
// ─── Public API ─────────────────────────────────────────────────────────────
@@ -875,6 +877,137 @@ pub fn extract_generics_tag(docblock: &str, tag: &str) -> Vec<(String, Vec<Strin
875877
results
876878
}
877879

880+
/// Attempt to synthesize a `ConditionalReturnType` from method-level
881+
/// `@template` annotations.
882+
///
883+
/// When a method (or standalone function) declares `@template T`,
884+
/// `@param class-string<T> $class`, and `@return T`, this creates a
885+
/// conditional return type that resolves `T` from the call-site argument.
886+
///
887+
/// For example, given:
888+
/// ```text
889+
/// @template T
890+
/// @param class-string<T> $class
891+
/// @return T
892+
/// ```
893+
/// Calling `find(User::class)` will resolve the return type to `User`.
894+
///
895+
/// Returns `None` when:
896+
/// - No template params are provided
897+
/// - The return type does not reference a template param
898+
/// - No `@param class-string<T>` annotation is found for the template param
899+
/// - An existing conditional return type is already present (pass `true`
900+
/// for `has_existing_conditional` to skip synthesis)
901+
pub fn synthesize_template_conditional(
902+
docblock: &str,
903+
template_params: &[String],
904+
return_type: Option<&str>,
905+
has_existing_conditional: bool,
906+
) -> Option<ConditionalReturnType> {
907+
// Don't override an existing conditional return type.
908+
if has_existing_conditional {
909+
return None;
910+
}
911+
912+
if template_params.is_empty() {
913+
return None;
914+
}
915+
916+
let ret = return_type?;
917+
918+
// Strip nullable prefix so that `?T` matches template param `T`.
919+
let stripped = ret.strip_prefix('?').unwrap_or(ret);
920+
921+
// Check if the (stripped) return type is one of the template params.
922+
if !template_params.iter().any(|t| t == stripped) {
923+
return None;
924+
}
925+
926+
// Find a `@param class-string<T> $paramName` annotation for this
927+
// template param, and extract the parameter name (without `$`).
928+
let param_name = find_class_string_param_name(docblock, stripped)?;
929+
930+
Some(ConditionalReturnType::Conditional {
931+
param_name,
932+
condition: ParamCondition::ClassString,
933+
// `then_type` is unused for ClassString — the resolver extracts
934+
// the class name directly from the argument (e.g. `User::class`
935+
// → `"User"`).
936+
then_type: Box::new(ConditionalReturnType::Concrete("mixed".into())),
937+
// `else_type` is used when the argument is not a `::class`
938+
// literal — `mixed` will produce `None` from resolution, which
939+
// lets the caller fall back to the plain return type.
940+
else_type: Box::new(ConditionalReturnType::Concrete("mixed".into())),
941+
})
942+
}
943+
944+
/// Search a docblock for a `@param class-string<T> $paramName` annotation
945+
/// where `T` matches the given `template_name`.
946+
///
947+
/// Returns the parameter name **without** the `$` prefix, or `None` if no
948+
/// matching annotation is found.
949+
///
950+
/// Handles common type variants:
951+
/// - `class-string<T>`
952+
/// - `?class-string<T>` (nullable)
953+
/// - `class-string<T>|null` (union with null)
954+
fn find_class_string_param_name(docblock: &str, template_name: &str) -> Option<String> {
955+
let inner = docblock
956+
.trim()
957+
.strip_prefix("/**")
958+
.unwrap_or(docblock)
959+
.strip_suffix("*/")
960+
.unwrap_or(docblock);
961+
962+
let pattern = format!("class-string<{}>", template_name);
963+
964+
for line in inner.lines() {
965+
let trimmed = line.trim().trim_start_matches('*').trim();
966+
967+
if let Some(rest) = trimmed.strip_prefix("@param") {
968+
let rest = rest.trim_start();
969+
if rest.is_empty() {
970+
continue;
971+
}
972+
973+
// Extract the full type token (respects `<…>` nesting).
974+
let (type_token, remainder) = split_type_token(rest);
975+
976+
// Check if the type token contains `class-string<T>`.
977+
// We strip `?` prefix and check for the pattern.
978+
let check = type_token.strip_prefix('?').unwrap_or(type_token);
979+
// Also handle `class-string<T>|null` — split on `|` and
980+
// check each part.
981+
let matches = check.split('|').any(|part| part.trim() == pattern);
982+
983+
if !matches {
984+
continue;
985+
}
986+
987+
// The next token after the type should be `$paramName`.
988+
// However, `split_type_token` splits at the closing `>`,
989+
// so if the type is `class-string<T>|null`, the remainder
990+
// will be `|null $class`. Skip any union continuation
991+
// (`|part`) before looking for the `$` variable name.
992+
let mut search = remainder;
993+
while let Some(rest) = search.strip_prefix('|') {
994+
// Skip `|unionPart` — the next whitespace-delimited
995+
// token is the union type, not the variable name.
996+
let rest = rest.trim_start();
997+
let (_, after) = split_type_token(rest);
998+
search = after;
999+
}
1000+
if let Some(var_name) = search.split_whitespace().next()
1001+
&& let Some(name) = var_name.strip_prefix('$')
1002+
{
1003+
return Some(name.to_string());
1004+
}
1005+
}
1006+
}
1007+
1008+
None
1009+
}
1010+
8781011
/// Split a comma-separated generic argument list, respecting `<…>` and `(…)`
8791012
/// nesting. Returns cleaned argument strings.
8801013
///

src/parser/classes.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,25 @@ impl Backend {
389389
docblock::get_docblock_text_for_node(ctx.trivias, ctx.content, method)
390390
.and_then(docblock::extract_conditional_return_type);
391391

392+
// If no explicit conditional return type was found,
393+
// try to synthesize one from method-level @template
394+
// annotations. For example:
395+
// @template T
396+
// @param class-string<T> $class
397+
// @return T
398+
// becomes a conditional that resolves T from the
399+
// call-site argument (e.g. find(User::class) → User).
400+
let conditional = conditional.or_else(|| {
401+
let doc = docblock_text?;
402+
let tpl_params = docblock::extract_template_params(doc);
403+
docblock::synthesize_template_conditional(
404+
doc,
405+
&tpl_params,
406+
effective.as_deref(),
407+
false,
408+
)
409+
});
410+
392411
let deprecated = docblock_text.is_some_and(docblock::has_deprecated_tag);
393412

394413
(effective, conditional, deprecated)

src/parser/functions.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,25 @@ impl Backend {
5454
let conditional =
5555
docblock_text.and_then(docblock::extract_conditional_return_type);
5656

57+
// If no explicit conditional return type was found,
58+
// try to synthesize one from function-level @template
59+
// annotations. For example:
60+
// @template T
61+
// @param class-string<T> $class
62+
// @return T
63+
// becomes a conditional that resolves T from the
64+
// call-site argument (e.g. resolve(User::class) → User).
65+
let conditional = conditional.or_else(|| {
66+
let doc = docblock_text?;
67+
let tpl_params = docblock::extract_template_params(doc);
68+
docblock::synthesize_template_conditional(
69+
doc,
70+
&tpl_params,
71+
effective.as_deref(),
72+
false,
73+
)
74+
});
75+
5776
let assertions = docblock_text
5877
.map(docblock::extract_type_assertions)
5978
.unwrap_or_default();

0 commit comments

Comments
 (0)