Skip to content

Commit 2193b73

Browse files
committed
Add support for object shapes
1 parent d9c43e5 commit 2193b73

7 files changed

Lines changed: 1386 additions & 19 deletions

File tree

example.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1681,3 +1681,75 @@ function getAppConfig(): array { return []; }
16811681
// → selecting 'host' produces $config['host']
16821682
// $config[''] — cursor between quotes, '] is auto-inserted
16831683
// → selecting 'host' produces $config['host']
1684+
1685+
// ─── Object Shape Completion ────────────────────────────────────────────────
1686+
//
1687+
// PHPStan object shapes describe anonymous objects with typed properties:
1688+
//
1689+
// object{foo: int, bar: string}
1690+
// object{foo: int, bar?: string} — bar is optional
1691+
// object{'foo': int, "bar": string} — quoted property names
1692+
// object{foo: int}&\stdClass — intersected with stdClass
1693+
//
1694+
// PHPantomLSP resolves object shapes from @return, @param, and @var
1695+
// annotations and offers property completions via `->`.
1696+
1697+
class ObjectShapeDemo {
1698+
/**
1699+
* @return object{name: string, age: int, active: bool}
1700+
*/
1701+
public function getProfile(): object {
1702+
return (object) [];
1703+
}
1704+
1705+
/**
1706+
* @return object{user: User, meta: object{page: int, total: int}}
1707+
*/
1708+
public function getResult(): object {
1709+
return (object) [];
1710+
}
1711+
1712+
/**
1713+
* @param object{host: string, port: int, ssl: bool} $config
1714+
*/
1715+
public function connect(object $config): void {
1716+
$config->host; // Resolved: string
1717+
$config->port; // Resolved: int
1718+
$config->ssl; // Resolved: bool
1719+
}
1720+
1721+
public function demo(): void {
1722+
// Basic object shape — property completions
1723+
$profile = $this->getProfile();
1724+
$profile->name; // Resolved: string
1725+
$profile->age; // Resolved: int
1726+
$profile->active; // Resolved: bool
1727+
1728+
// Chained access — object shape value type resolves to a class
1729+
$result = $this->getResult();
1730+
$result->user->getEmail(); // Resolved: User::getEmail()
1731+
$result->user->getName(); // Resolved: User::getName()
1732+
1733+
// Nested object shapes — chain through inner object shape
1734+
$result->meta->page; // Resolved: int
1735+
$result->meta->total; // Resolved: int
1736+
}
1737+
}
1738+
1739+
// Object shapes work with @var annotations too
1740+
/** @var object{title: string, score: float} $item */
1741+
$item = getUnknownValue();
1742+
$item->title; // Resolved: string
1743+
$item->score; // Resolved: float
1744+
1745+
// Nullable object shapes work in union types
1746+
/** @var object{status: string, code: int}|null $response */
1747+
$response = getUnknownValue();
1748+
$response->status; // Resolved: string
1749+
$response->code; // Resolved: int
1750+
1751+
// Intersection with \stdClass (makes properties writable)
1752+
/** @var object{name: string, value: int}&\stdClass $obj */
1753+
$obj = getUnknownValue();
1754+
$obj->name; // Resolved: string
1755+
$obj->value; // Resolved: int

src/completion/resolver.rs

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
/// resolution at call sites.
2020
use crate::Backend;
2121
use crate::docblock;
22-
use crate::docblock::types::{parse_generic_args, split_union_depth0, strip_generics};
22+
use crate::docblock::types::{
23+
parse_generic_args, split_intersection_depth0, split_union_depth0, strip_generics,
24+
};
2325
use crate::inheritance::apply_generic_args;
2426
use crate::types::*;
2527

@@ -992,12 +994,15 @@ impl Backend {
992994
return results;
993995
}
994996

995-
// ── Intersection type: split on `&` and resolve each part ──
997+
// ── Intersection type: split on `&` at depth 0 and resolve each part ──
996998
// `User&JsonSerializable` means the value satisfies *all* listed
997999
// types, so completions should include members from every part.
998-
if hint.contains('&') {
1000+
// Uses depth-aware splitting so that `&` inside `{…}` or `<…>`
1001+
// (e.g. `object{foo: A&B}`) is not treated as a top-level split.
1002+
let intersection_parts = split_intersection_depth0(hint);
1003+
if intersection_parts.len() > 1 {
9991004
let mut results = Vec::new();
1000-
for part in hint.split('&') {
1005+
for part in intersection_parts {
10011006
let part = part.trim();
10021007
if part.is_empty() {
10031008
continue;
@@ -1013,6 +1018,44 @@ impl Backend {
10131018
return results;
10141019
}
10151020

1021+
// ── Object shape: `object{foo: int, bar: string}` ──────────────
1022+
// Synthesise a ClassInfo with public properties from the shape
1023+
// entries so that `$var->foo` resolves through normal property
1024+
// resolution. Object shape properties are read-only.
1025+
if docblock::types::is_object_shape(hint)
1026+
&& let Some(entries) = docblock::parse_object_shape(hint)
1027+
{
1028+
let properties = entries
1029+
.into_iter()
1030+
.map(|e| PropertyInfo {
1031+
name: e.key,
1032+
type_hint: Some(e.value_type),
1033+
is_static: false,
1034+
visibility: Visibility::Public,
1035+
is_deprecated: false,
1036+
})
1037+
.collect();
1038+
1039+
let synthetic = ClassInfo {
1040+
name: "__object_shape".to_string(),
1041+
methods: vec![],
1042+
properties,
1043+
constants: vec![],
1044+
start_offset: 0,
1045+
end_offset: 0,
1046+
parent_class: None,
1047+
used_traits: vec![],
1048+
mixins: vec![],
1049+
is_final: false,
1050+
is_deprecated: false,
1051+
template_params: vec![],
1052+
extends_generics: vec![],
1053+
implements_generics: vec![],
1054+
use_generics: vec![],
1055+
};
1056+
return vec![synthetic];
1057+
}
1058+
10161059
// self / static / $this always refer to the owning class.
10171060
// In docblocks `@return $this` means "the instance the method is
10181061
// called on" — identical to `static` for inheritance, but when the

src/completion/variable_resolution.rs

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -181,20 +181,51 @@ impl Backend {
181181
let mut param_results: Vec<ClassInfo> = Vec::new();
182182
for param in method.parameter_list.parameters.iter() {
183183
let pname = param.variable.name.to_string();
184-
if pname == ctx.var_name
185-
&& let Some(hint) = &param.hint
186-
{
187-
let type_str = Self::extract_hint_string(hint);
188-
let resolved = Self::type_hint_to_classes(
189-
&type_str,
190-
&ctx.current_class.name,
191-
ctx.all_classes,
192-
ctx.class_loader,
193-
);
194-
if !resolved.is_empty() {
195-
param_results = resolved;
184+
if pname == ctx.var_name {
185+
// Try the native AST type hint first.
186+
let native_type_str =
187+
param.hint.as_ref().map(|h| Self::extract_hint_string(h));
188+
189+
let resolved_from_native = native_type_str
190+
.as_deref()
191+
.map(|ts| {
192+
Self::type_hint_to_classes(
193+
ts,
194+
&ctx.current_class.name,
195+
ctx.all_classes,
196+
ctx.class_loader,
197+
)
198+
})
199+
.unwrap_or_default();
200+
201+
if !resolved_from_native.is_empty() {
202+
param_results = resolved_from_native;
196203
break;
197204
}
205+
206+
// Native hint didn't resolve (e.g. `object`, `mixed`).
207+
// Fall back to the `@param` docblock annotation which
208+
// may carry a more specific type such as
209+
// `object{foo: int, bar: string}`.
210+
let method_start = method.span().start.offset as usize;
211+
if let Some(raw_docblock_type) =
212+
crate::docblock::find_iterable_raw_type_in_source(
213+
ctx.content,
214+
method_start,
215+
ctx.var_name,
216+
)
217+
{
218+
let resolved = Self::type_hint_to_classes(
219+
&raw_docblock_type,
220+
&ctx.current_class.name,
221+
ctx.all_classes,
222+
ctx.class_loader,
223+
);
224+
if !resolved.is_empty() {
225+
param_results = resolved;
226+
break;
227+
}
228+
}
198229
}
199230
}
200231

src/docblock/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,6 @@ pub use conditional::extract_conditional_return_type;
5050
// Type utilities
5151
pub use types::{
5252
base_class_name, clean_type, extract_array_shape_value_type, extract_generic_key_type,
53-
extract_generic_value_type, parse_array_shape,
53+
extract_generic_value_type, extract_object_shape_property_type, is_object_shape,
54+
parse_array_shape, parse_object_shape, split_intersection_depth0,
5455
};

src/docblock/types.rs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,44 @@ pub(crate) fn split_union_depth0(s: &str) -> Vec<&str> {
116116
parts
117117
}
118118

119+
/// Split a type string on `&` (intersection) at depth 0, respecting
120+
/// `<…>`, `(…)`, and `{…}` nesting.
121+
///
122+
/// This is necessary so that intersection operators inside generic
123+
/// parameters or object/array shapes (e.g. `object{foo: A&B}`) are not
124+
/// mistaken for top-level intersection splits.
125+
///
126+
/// # Examples
127+
///
128+
/// - `"User&JsonSerializable"` → `["User", "JsonSerializable"]`
129+
/// - `"object{foo: int}&\stdClass"` → `["object{foo: int}", "\stdClass"]`
130+
/// - `"object{foo: A&B}"` → `["object{foo: A&B}"]` (no split — `&` is nested)
131+
pub fn split_intersection_depth0(s: &str) -> Vec<&str> {
132+
let mut parts = Vec::new();
133+
let mut depth_angle = 0i32;
134+
let mut depth_paren = 0i32;
135+
let mut depth_brace = 0i32;
136+
let mut start = 0;
137+
138+
for (i, c) in s.char_indices() {
139+
match c {
140+
'<' => depth_angle += 1,
141+
'>' => depth_angle -= 1,
142+
'(' => depth_paren += 1,
143+
')' => depth_paren -= 1,
144+
'{' => depth_brace += 1,
145+
'}' => depth_brace -= 1,
146+
'&' if depth_angle == 0 && depth_paren == 0 && depth_brace == 0 => {
147+
parts.push(&s[start..i]);
148+
start = i + c.len_utf8();
149+
}
150+
_ => {}
151+
}
152+
}
153+
parts.push(&s[start..]);
154+
parts
155+
}
156+
119157
/// Clean a raw type string from a docblock, **preserving** generic
120158
/// parameters so that downstream resolution can apply generic
121159
/// substitution.
@@ -718,3 +756,108 @@ pub fn extract_array_shape_value_type(type_str: &str, key: &str) -> Option<Strin
718756
.find(|e| e.key == key)
719757
.map(|e| e.value_type)
720758
}
759+
760+
// ─── Object Shape Parsing ───────────────────────────────────────────────────
761+
762+
/// Parse a PHPStan object shape type string into its constituent entries.
763+
///
764+
/// Object shapes describe an anonymous object with typed properties:
765+
///
766+
/// # Examples
767+
///
768+
/// - `"object{foo: int, bar: string}"` → two entries
769+
/// - `"object{foo: int, bar?: string}"` → "bar" is optional
770+
/// - `"object{'foo': int, \"bar\": string}"` → quoted property names
771+
/// - `"object{foo: int, bar: string}&\stdClass"` → intersection ignored here
772+
///
773+
/// The returned entries reuse [`ArrayShapeEntry`] since the structure is
774+
/// identical (key name, value type, optional flag).
775+
///
776+
/// Returns `None` if the type is not an object shape.
777+
pub fn parse_object_shape(type_str: &str) -> Option<Vec<ArrayShapeEntry>> {
778+
let s = type_str.strip_prefix('\\').unwrap_or(type_str);
779+
let s = s.strip_prefix('?').unwrap_or(s);
780+
781+
// Must start with `object{` (case-insensitive base).
782+
let brace_pos = s.find('{')?;
783+
let base = &s[..brace_pos];
784+
if !base.eq_ignore_ascii_case("object") {
785+
return None;
786+
}
787+
788+
// Extract the content between `{` and the matching `}`.
789+
let rest = &s[brace_pos + 1..];
790+
let close_pos = find_matching_brace_close(rest);
791+
let inner = rest[..close_pos].trim();
792+
793+
if inner.is_empty() {
794+
return Some(vec![]);
795+
}
796+
797+
// Reuse the same splitting and key-value parsing as array shapes —
798+
// the syntax is identical (`key: Type`, `key?: Type`, quoted keys).
799+
let raw_entries = split_shape_entries(inner);
800+
let mut entries = Vec::with_capacity(raw_entries.len());
801+
802+
for raw in raw_entries {
803+
let raw = raw.trim();
804+
if raw.is_empty() {
805+
continue;
806+
}
807+
808+
if let Some((key_part, value_part)) = split_shape_key_value(raw) {
809+
let key_trimmed = key_part.trim();
810+
let value_trimmed = value_part.trim();
811+
812+
let (key, optional) = if let Some(k) = key_trimmed.strip_suffix('?') {
813+
(k.to_string(), true)
814+
} else {
815+
(key_trimmed.to_string(), false)
816+
};
817+
818+
let key = strip_shape_key_quotes(&key);
819+
820+
entries.push(ArrayShapeEntry {
821+
key,
822+
value_type: value_trimmed.to_string(),
823+
optional,
824+
});
825+
}
826+
// Object shapes don't have positional entries — skip anything
827+
// without an explicit key.
828+
}
829+
830+
Some(entries)
831+
}
832+
833+
/// Check whether a type string is an object shape (`object{…}`).
834+
///
835+
/// Returns `true` for `"object{foo: int}"`, `"?object{bar: string}"`,
836+
/// and `"\object{baz: bool}"`. Returns `false` for bare `"object"`.
837+
pub fn is_object_shape(type_str: &str) -> bool {
838+
let s = type_str.strip_prefix('\\').unwrap_or(type_str);
839+
let s = s.strip_prefix('?').unwrap_or(s);
840+
// Check for `object{` case-insensitively, but only when `{` immediately
841+
// follows the word `object` (no intervening whitespace).
842+
if let Some(brace_pos) = s.find('{') {
843+
let base = &s[..brace_pos];
844+
base.eq_ignore_ascii_case("object")
845+
} else {
846+
false
847+
}
848+
}
849+
850+
/// Look up the value type for a specific property in an object shape.
851+
///
852+
/// Given a type like `"object{name: string, user: User}"` and key `"user"`,
853+
/// returns `Some("User")`.
854+
///
855+
/// Returns `None` if the type is not an object shape or the property
856+
/// is not found.
857+
pub fn extract_object_shape_property_type(type_str: &str, prop: &str) -> Option<String> {
858+
let entries = parse_object_shape(type_str)?;
859+
entries
860+
.into_iter()
861+
.find(|e| e.key == prop)
862+
.map(|e| e.value_type)
863+
}

src/lib.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
//! `@mixin`, `@deprecated`, `@phpstan-assert`, docblock text retrieval)
2323
//! - `docblock::conditional` — PHPStan conditional return type parsing
2424
//! - `docblock::types` — type cleaning utilities (`clean_type`, `strip_nullable`,
25-
//! `is_scalar`, `split_type_token`) and PHPStan array shape parsing
26-
//! (`parse_array_shape`, `extract_array_shape_value_type`)
25+
//! `is_scalar`, `split_type_token`), PHPStan array shape parsing
26+
//! (`parse_array_shape`, `extract_array_shape_value_type`), and object shape
27+
//! parsing (`parse_object_shape`, `extract_object_shape_property_type`,
28+
//! `is_object_shape`)
2729
2830
use std::collections::HashMap;
2931
use std::path::PathBuf;

0 commit comments

Comments
 (0)