Skip to content

Commit e567582

Browse files
lucasacoutinhoAJenbo
authored andcommitted
feat: infer property types from constructor assignments
1 parent 50e46d2 commit e567582

6 files changed

Lines changed: 1127 additions & 2 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- **Untyped property type inference from constructor.** Go-to-definition and completion now resolve property types that have no type declaration by inspecting the constructor body for `$this->prop = new ClassName()` assignments and promoted parameter defaults like `private $prop = new ClassName()`. PHPDoc generation also uses the inferred type: typing `/**` above an untyped property produces `@var Repository` instead of `@var mixed` when the constructor assigns `new Repository()`. This covers common patterns in legacy PHP codebases and PHP 8.1+ code using `new` in initializers. Contributed by @lucasacoutinho in https://github.com/AJenbo/phpantom_lsp/pull/81.
1213
- **PHPCS diagnostic proxy.** PHP_CodeSniffer violations are surfaced as LSP diagnostics. When `squizlabs/php_codesniffer` is in `require-dev`, PHPCS is auto-detected via Composer's bin-dir; otherwise it falls back to `$PATH`. Each violation uses the sniff name as the diagnostic code (e.g. `PSR12.Files.FileHeader.MissingPHPVersion`), maps PHPCS error/warning levels to LSP severity, and marks fixable violations in diagnostic data. Runs in a dedicated background worker with the same debounce and single-pending-URI design as the PHPStan proxy. Configurable under `[phpcs]` in `.phpantom.toml` with `command`, `standard`, and `timeout` options.
1314
- **Linked editing ranges.** Place the cursor on a variable and all occurrences within its definition region (from one assignment to the next) enter linked editing mode. Typing a new name updates every occurrence simultaneously without affecting reassigned uses of the same variable name. Ranges exclude the leading `$` sigil so that typing before a variable (e.g. wrapping it in a function call) does not propagate to other occurrences.
1415
- **Invalid class-like kind diagnostics.** Flags class-like names used in positions where their kind is guaranteed to fail at runtime: `new` on abstract classes, interfaces, traits, or enums; `extends` on a final class, interface, or trait; `implements` with a non-interface; trait `use` with a non-trait; `instanceof` with a trait (always false); `catch` with a non-Throwable type or trait; and traits in native type-hint positions. Severity follows PHP semantics: unconditional fatal errors are Error, runtime-conditional failures are Warning.

example.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3369,6 +3369,38 @@ private function acceptTrait(JsonSerializer $x): JsonSerializer
33693369
}
33703370

33713371

3372+
// ── Untyped Property Inference ──────────────────────────────────────────────
3373+
// Properties without type declarations have their types inferred from
3374+
// constructor assignments (`$this->prop = new Foo()`) and promoted
3375+
// parameter defaults (`private $prop = new Foo()`). Trigger completion
3376+
// after `->` on the property to see methods from the inferred type.
3377+
3378+
class UntypedPropertyInferenceDemo
3379+
{
3380+
private $repository;
3381+
private $logger;
3382+
3383+
public function __construct(
3384+
private $defaultRepo = new ScaffoldingUntypedRepo(),
3385+
) {
3386+
$this->repository = new ScaffoldingUntypedRepo();
3387+
$this->logger = new ScaffoldingUntypedLogger();
3388+
}
3389+
3390+
public function demo(): void
3391+
{
3392+
// Constructor body assignment: $this->repository = new ScaffoldingUntypedRepo()
3393+
$this->repository->findById(1); // resolves ScaffoldingUntypedRepo::findById()
3394+
3395+
// Constructor body assignment: $this->logger = new ScaffoldingUntypedLogger()
3396+
$this->logger->info('hello'); // resolves ScaffoldingUntypedLogger::info()
3397+
3398+
// Promoted parameter default: private $defaultRepo = new ScaffoldingUntypedRepo()
3399+
$this->defaultRepo->findById(42); // resolves ScaffoldingUntypedRepo::findById()
3400+
}
3401+
}
3402+
3403+
33723404
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
33733405
// ┃ SCAFFOLDING — Supporting definitions below this line. ┃
33743406

@@ -3437,6 +3469,20 @@ public function map(string $signature, mixed $source): Pen|Marker
34373469
// The same applies to `interface Foo extends \Stringable`.
34383470

34393471

3472+
// ── Untyped Property Inference Scaffolding ──────────────────────────────────
3473+
3474+
class ScaffoldingUntypedRepo
3475+
{
3476+
public function findById(int $id): Pen { return new Pen('found'); }
3477+
public function save(Pen $pen): void {}
3478+
}
3479+
3480+
class ScaffoldingUntypedLogger
3481+
{
3482+
public function info(string $msg): void {}
3483+
public function error(string $msg): void {}
3484+
}
3485+
34403486
// ── Demo-Specific Scaffolding ───────────────────────────────────────────────
34413487

34423488
// ── Inherited Docblock Scaffolding ──────────────────────────────────────────
@@ -6091,6 +6137,14 @@ function runDemoAssertions(): void
60916137
assert($shapeEntry['tool'] instanceof Pen, 'Shape key from conditional loop must resolve to Pen');
60926138
}
60936139

6140+
// ── Untyped property inference from constructor ─────────────────────
6141+
$untypedDemo = new UntypedPropertyInferenceDemo();
6142+
// The scaffolding repo's findById() returns Pen, so we can verify
6143+
// that the inferred type propagates through the property chain.
6144+
$repoRef = new ScaffoldingUntypedRepo();
6145+
$found = $repoRef->findById(1);
6146+
assert($found instanceof Pen, 'ScaffoldingUntypedRepo::findById() must return Pen');
6147+
60946148
echo "All assertions passed.\n";
60956149
}
60966150

src/completion/phpdoc/generation.rs

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,13 @@ pub fn try_generate_docblock(
8282
return None;
8383
}
8484

85-
let sym = parse_declaration_info(&remaining);
85+
let mut sym = parse_declaration_info(&remaining);
86+
87+
// For untyped properties, try to fill in the type from the parsed
88+
// class data (e.g. constructor-inferred `$this->prop = new Foo()`).
89+
if matches!(context, DocblockContext::Property) && sym.type_hint.is_none() {
90+
enrich_property_type_from_class(&mut sym, content, position, local_classes);
91+
}
8692

8793
let snippet = build_docblock_snippet(
8894
&context,
@@ -176,7 +182,13 @@ pub fn try_generate_docblock_on_enter(
176182
return None;
177183
}
178184

179-
let sym = parse_declaration_info(&after_block);
185+
let mut sym = parse_declaration_info(&after_block);
186+
187+
// For untyped properties, try to fill in the type from the parsed
188+
// class data (e.g. constructor-inferred `$this->prop = new Foo()`).
189+
if matches!(context, DocblockContext::Property) && sym.type_hint.is_none() {
190+
enrich_property_type_from_class(&mut sym, content, position, local_classes);
191+
}
180192

181193
// Build the docblock as plain text (no snippet tab stops).
182194
let plain = build_docblock_plain(
@@ -846,6 +858,54 @@ fn extract_return_type_from_decl(after_close: &str) -> Option<PhpType> {
846858
}
847859

848860
/// Extract the type hint from a property or constant declaration.
861+
/// Enrich an untyped property's [`SymbolInfo::type_hint`] by looking up
862+
/// the property in the file's parsed class data.
863+
///
864+
/// When a property has no native type hint or docblock, the constructor-
865+
/// inference pass in `extract_class_like_members` may have filled in a
866+
/// type from `$this->prop = new ClassName()` or a promoted parameter
867+
/// default. This function finds that inferred type and copies it into
868+
/// `sym` so that the generated `@var` tag uses the concrete class name
869+
/// instead of `mixed`.
870+
///
871+
/// The type is shortened (leading namespace segments stripped) for
872+
/// readability in the generated docblock.
873+
fn enrich_property_type_from_class(
874+
sym: &mut SymbolInfo,
875+
content: &str,
876+
position: Position,
877+
local_classes: &[Arc<ClassInfo>],
878+
) {
879+
// Extract the bare property name (strip the `$` prefix).
880+
let prop_name = sym
881+
.variable_name
882+
.as_ref()
883+
.and_then(|v| v.strip_prefix('$'))
884+
.unwrap_or("");
885+
if prop_name.is_empty() {
886+
return;
887+
}
888+
889+
// Find the enclosing class by byte offset.
890+
let cursor_offset = position_to_byte_offset(content, position) as u32;
891+
let enclosing = local_classes
892+
.iter()
893+
.find(|cls| cls.start_offset <= cursor_offset && cursor_offset <= cls.end_offset);
894+
let Some(cls) = enclosing else {
895+
return;
896+
};
897+
898+
// Look up the property. Only use the type when it was inferred
899+
// (the native_type_hint is None — if it were set, the source-text
900+
// parser would already have extracted it).
901+
if let Some(prop) = cls.properties.iter().find(|p| p.name == prop_name)
902+
&& prop.native_type_hint.is_none()
903+
&& let Some(ref inferred) = prop.type_hint
904+
{
905+
sym.type_hint = Some(inferred.shorten());
906+
}
907+
}
908+
849909
fn extract_property_type(decl: &str) -> Option<PhpType> {
850910
// Strip modifiers.
851911
let modifiers = [

src/parser/classes.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1735,13 +1735,43 @@ impl Backend {
17351735
doc_ctx: Option<&DocblockCtx<'a>>,
17361736
class_template_params: &[String],
17371737
) -> ExtractedMembers {
1738+
/// Resolve a short class name to its FQN using the file's use-map
1739+
/// and namespace from [`DocblockCtx`]. When no context is
1740+
/// available the name is returned as-is.
1741+
fn resolve_name_via_ctx(name: &str, doc_ctx: Option<&DocblockCtx<'_>>) -> String {
1742+
// Already fully-qualified — strip the leading `\`.
1743+
if let Some(stripped) = name.strip_prefix('\\') {
1744+
return stripped.to_string();
1745+
}
1746+
let Some(ctx) = doc_ctx else {
1747+
return name.to_string();
1748+
};
1749+
// Check use-map (handles both unqualified and qualified names).
1750+
if let Some(pos) = name.find('\\') {
1751+
let first = &name[..pos];
1752+
let rest = &name[pos..];
1753+
if let Some(fqn) = ctx.use_map.get(first) {
1754+
return format!("{fqn}{rest}");
1755+
}
1756+
} else if let Some(fqn) = ctx.use_map.get(name) {
1757+
return fqn.clone();
1758+
}
1759+
// Prepend current namespace if available.
1760+
if let Some(ref ns) = ctx.namespace {
1761+
format!("{ns}\\{name}")
1762+
} else {
1763+
name.to_string()
1764+
}
1765+
}
1766+
17381767
let mut methods = Vec::new();
17391768
let mut properties = Vec::new();
17401769
let mut constants = Vec::new();
17411770
let mut used_traits = Vec::new();
17421771
let mut trait_precedences = Vec::new();
17431772
let mut trait_aliases = Vec::new();
17441773
let mut inline_use_generics: Vec<(String, Vec<PhpType>)> = Vec::new();
1774+
let mut constructor_body: Option<&MethodBody<'_>> = None;
17451775

17461776
for member in members {
17471777
match member {
@@ -1930,6 +1960,7 @@ impl Backend {
19301960
// (e.g. `@param list<User> $users` vs native `array $users`).
19311961
// We apply `resolve_effective_type()` to pick the winner.
19321962
if name == "__construct" {
1963+
constructor_body = Some(&method.body);
19331964
for param in method.parameter_list.parameters.iter() {
19341965
if param.is_promoted_property() {
19351966
let raw_name = param.variable.name.to_string();
@@ -1970,6 +2001,22 @@ impl Backend {
19702001
saved_native_hint.clone()
19712002
};
19722003

2004+
// When no type hint is available, infer from `new ClassName()`
2005+
// default values (PHP 8.1+: `private $repo = new Repo()`).
2006+
// Resolve to FQN eagerly so downstream code does not
2007+
// need short-name resolution logic.
2008+
let type_hint = type_hint.or_else(|| {
2009+
let dv = param.default_value.as_ref()?;
2010+
if let Expression::Instantiation(inst) = dv.value
2011+
&& let Expression::Identifier(ident) = inst.class
2012+
{
2013+
let raw = ident.value().to_string();
2014+
let fqn = resolve_name_via_ctx(&raw, doc_ctx);
2015+
return Some(PhpType::Named(fqn));
2016+
}
2017+
None
2018+
});
2019+
19732020
let prop_name_offset = param.variable.span.start.offset;
19742021
properties.push(PropertyInfo {
19752022
name: prop_name,
@@ -2396,6 +2443,35 @@ impl Backend {
23962443
}
23972444
}
23982445

2446+
// Infer types for untyped properties from constructor assignments.
2447+
// Scans the constructor body for `$this->prop = new ClassName()`
2448+
// and fills in the type_hint when not set by declaration or docblock.
2449+
// Only applies when neither native type hint nor docblock type is present.
2450+
// Resolves to FQN eagerly so downstream code does not need
2451+
// short-name resolution logic.
2452+
if let Some(MethodBody::Concrete(concrete)) = constructor_body {
2453+
for stmt in concrete.statements.iter() {
2454+
if let Statement::Expression(expr_stmt) = stmt
2455+
&& let Expression::Assignment(assign) = expr_stmt.expression
2456+
&& let Expression::Access(Access::Property(pa)) = assign.lhs
2457+
&& let Expression::Variable(Variable::Direct(dv)) = pa.object
2458+
&& dv.name == "$this"
2459+
&& let ClassLikeMemberSelector::Identifier(ident) = &pa.property
2460+
&& let Expression::Instantiation(inst) = assign.rhs
2461+
&& let Expression::Identifier(class_ident) = inst.class
2462+
{
2463+
let prop_name = ident.value.to_string();
2464+
if let Some(prop) = properties.iter_mut().find(|p| {
2465+
p.name == prop_name && p.type_hint.is_none() && p.native_type_hint.is_none()
2466+
}) {
2467+
let raw = class_ident.value().to_string();
2468+
let fqn = resolve_name_via_ctx(&raw, doc_ctx);
2469+
prop.type_hint = Some(PhpType::Named(fqn));
2470+
}
2471+
}
2472+
}
2473+
}
2474+
23992475
ExtractedMembers {
24002476
methods,
24012477
properties,

0 commit comments

Comments
 (0)