Skip to content

Commit 673af85

Browse files
committed
Add #[ArrayShape] attribute support for array shape inference
Functions and methods annotated with #[ArrayShape(["key" => "type", ...])] now produce array shape key completions, hover type info, and correct type resolution. Includes tests for function, method, and union return types. Removes completed todo item from docs/todo.md and docs/todo/completion.md. Changelog updated.
1 parent 9f125e3 commit 673af85

9 files changed

Lines changed: 357 additions & 41 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+
- **`#[ArrayShape]` attribute support.** Functions and methods annotated with `#[ArrayShape(["key" => "type", ...])]` (used by ~84 phpstorm-stubs entries) now produce array shape key completions, hover type info, and correct type resolution. Affects commonly used functions like `parse_url`, `stat`, `pathinfo`, `gc_status`, `getimagesize`, and `session_get_cookie_params`.
1213
- **Convert to arrow function.** A new `refactor.rewrite` code action converts single-expression closures to arrow functions (`function($x) { return $x * 2; }` to `fn($x) => $x * 2`). The action is only offered when the conversion is safe: single return statement, no by-reference `use` captures, no `void`/`never` return type, and PHP >= 7.4.
1314
- **Convert switch to match.** A new `refactor.rewrite` code action converts `switch` statements to `match` expressions when all arms are single-expression returns or assignments to the same variable. Handles fall-through cases (merged with commas), trailing `break` removal, and `throw` arms. Requires PHP >= 8.0.
1415
- **Extract interface.** A new `refactor.extract` code action generates an interface from a concrete class. All public method signatures (excluding the constructor) are extracted into a new `{ClassName}Interface.php` file in the same directory, and the class is updated with `implements {ClassName}Interface`. Class-level and method-level `@template` tags are preserved when referenced by extracted methods.

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ within the same impact tier.
2525

2626
| # | Item | Impact | Effort |
2727
| --- | --------------------------------------------------------------------------------------------------------------------- | ---------- | ------ |
28-
| C2 | [`#[ArrayShape]` return shapes on stub functions](todo/completion.md#c2-arrayshape-return-shapes-on-stub-functions) | Medium | Medium |
2928
| D10 | [PHPMD diagnostic proxy](todo/diagnostics.md#d10-phpmd-diagnostic-proxy) | Low | Medium |
3029

3130
## Sprint 7 — 1.0 release & IDE extensions

docs/todo/completion.md

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -46,44 +46,6 @@ These functions have return type semantics that don't fit into either
4646

4747
---
4848

49-
## C2. `#[ArrayShape]` return shapes on stub functions
50-
51-
**Impact: Medium · Effort: Medium**
52-
53-
phpstorm-stubs annotate ~84 functions and methods with
54-
`#[ArrayShape(["key" => "type", ...])]` to declare the structure of
55-
their array return values. Almost none of these have a companion
56-
`@return array{...}` docblock, so the shape information is invisible
57-
to PHPantom. This affects commonly used functions like `parse_url`,
58-
`stat`, `pathinfo`, `gc_status`, `getimagesize`,
59-
`session_get_cookie_params`, `stream_get_meta_data`, and
60-
`password_get_info`.
61-
62-
```php
63-
#[ArrayShape(["lifetime" => "int", "path" => "string", "domain" => "string",
64-
"secure" => "bool", "httponly" => "bool", "samesite" => "string"])]
65-
function session_get_cookie_params(): array {}
66-
67-
#[ArrayShape(["runs" => "int", "collected" => "int", "threshold" => "int", "roots" => "int"])]
68-
function gc_status(): array {}
69-
```
70-
71-
**Attribute FQN:** `JetBrains\PhpStorm\ArrayShape`. Stub files import
72-
it via `use JetBrains\PhpStorm\ArrayShape;`. No aliases are used.
73-
Match by resolving through the `DocblockCtx` use-map and comparing the
74-
last segment of the resolved FQN (same pattern as `Deprecated` and
75-
`PhpStormStubsElementAvailable`).
76-
77-
**Implementation:** During function/method extraction, scan for the
78-
`ArrayShape` attribute. Parse the associative array literal in its
79-
argument to build an `array{key: type, ...}` string, and use it as
80-
the effective return type (or parameter type when applied to a
81-
parameter). This complements the existing docblock `array{...}`
82-
parsing and should feed into the same `return_type` field on
83-
`FunctionInfo` / `MethodInfo`.
84-
85-
---
86-
8749
## C3. Go-to-definition for array shape keys via bracket access
8850

8951
**Impact: Low-Medium · Effort: Medium**

src/completion/array_shape.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -532,14 +532,18 @@ impl Backend {
532532
&dummy_class
533533
}
534534
};
535+
let function_loader = self.function_loader(file_ctx);
535536
let resolved = crate::completion::variable::resolution::resolve_variable_types(
536537
var_name,
537538
effective_class,
538539
&file_ctx.classes,
539540
content,
540541
cursor_offset as u32,
541542
&class_loader,
542-
Loaders::default(),
543+
Loaders {
544+
function_loader: Some(&function_loader),
545+
..Loaders::default()
546+
},
543547
);
544548
if resolved.is_empty() {
545549
None

src/parser/classes.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1959,6 +1959,15 @@ impl Backend {
19591959
parsed_doc_type.as_ref(),
19601960
);
19611961

1962+
// Apply #[ArrayShape] override if present.
1963+
let effective = if let Some(ctx) = doc_ctx {
1964+
effective.map(|ty| {
1965+
super::apply_array_shape_override(ty, &method.attribute_lists, ctx)
1966+
})
1967+
} else {
1968+
effective
1969+
};
1970+
19621971
let conditional = docblock::extract_conditional_return_type_from_info(info);
19631972

19641973
// Extract method-level @template params, their bounds,
@@ -2064,8 +2073,18 @@ impl Backend {
20642073
// #[Deprecated] attribute on the method itself.
20652074
let depr_info =
20662075
merge_deprecation_info(None, &method.attribute_lists, doc_ctx);
2076+
2077+
// Apply #[ArrayShape] override even without a docblock.
2078+
let effective_ret = if let Some(ctx) = doc_ctx {
2079+
native_return_type.clone().map(|ty| {
2080+
super::apply_array_shape_override(ty, &method.attribute_lists, ctx)
2081+
})
2082+
} else {
2083+
native_return_type.clone()
2084+
};
2085+
20672086
(
2068-
native_return_type.clone(),
2087+
effective_ret,
20692088
None,
20702089
depr_info.message,
20712090
depr_info.replacement,

src/parser/functions.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,14 @@ impl Backend {
167167
parsed_doc_return.as_ref(),
168168
);
169169

170+
// If the effective return type is bare `array` (or
171+
// nullable/union containing array) and the function
172+
// has a `#[ArrayShape]` attribute, replace it with
173+
// the more specific shape type.
174+
let effective = effective.map(|ty| {
175+
super::apply_array_shape_override(ty, &func.attribute_lists, ctx)
176+
});
177+
170178
let conditional = info
171179
.as_ref()
172180
.and_then(docblock::extract_conditional_return_type_from_info);

src/parser/mod.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ const ATTR_ELEMENT_AVAILABLE: &str = "PhpStormStubsElementAvailable";
6868
/// Last segment of the `LanguageLevelTypeAware` attribute FQN.
6969
const ATTR_LANGUAGE_LEVEL_TYPE_AWARE: &str = "LanguageLevelTypeAware";
7070

71+
/// Last segment of the `ArrayShape` attribute FQN.
72+
const ATTR_ARRAY_SHAPE: &str = "ArrayShape";
73+
7174
/// Fully-qualified names (without leading `\`) that we recognise as
7275
/// deprecation attributes. Only the native PHP 8.4 `\Deprecated` and
7376
/// the JetBrains stubs `\JetBrains\PhpStorm\Deprecated` should match.
@@ -102,6 +105,13 @@ impl DocblockCtx<'_> {
102105
let canonical = self.resolve_attr_last_segment(name_str).unwrap_or(name_str);
103106
canonical == ATTR_LANGUAGE_LEVEL_TYPE_AWARE
104107
}
108+
109+
/// Check whether `attr_short_name` resolves to `ArrayShape`.
110+
fn is_array_shape_attr(&self, attr_short_name: &[u8]) -> bool {
111+
let name_str = bytes_to_str(attr_short_name);
112+
let canonical = self.resolve_attr_last_segment(name_str).unwrap_or(name_str);
113+
canonical == ATTR_ARRAY_SHAPE
114+
}
105115
}
106116

107117
// ─── PhpStormStubsElementAvailable Attribute Parsing ────────────────────────
@@ -325,6 +335,103 @@ pub(crate) fn extract_language_level_type_for_param(
325335
extract_language_level_type(&param.attribute_lists, ctx, php_version)
326336
}
327337

338+
/// Extract an `array{key: type, ...}` shape from a `#[ArrayShape]` attribute.
339+
///
340+
/// phpstorm-stubs annotate functions and methods with
341+
/// `#[ArrayShape(["key" => "type", ...])]` to declare the structure of
342+
/// their array return values. This function extracts that information
343+
/// and returns it as a `PhpType::ArrayShape`.
344+
pub(crate) fn extract_array_shape_type(
345+
attribute_lists: &Sequence<'_, attribute::AttributeList<'_>>,
346+
ctx: &DocblockCtx<'_>,
347+
) -> Option<PhpType> {
348+
use crate::php_type::ShapeEntry;
349+
350+
for attr_list in attribute_lists.iter() {
351+
for attr in attr_list.attributes.iter() {
352+
if !ctx.is_array_shape_attr(last_segment(attr.name.value())) {
353+
continue;
354+
}
355+
356+
let arg_list = attr.argument_list.as_ref()?;
357+
// The ArrayShape attribute takes a single positional argument:
358+
// an associative array literal ["key" => "type", ...].
359+
let first_arg = arg_list.arguments.first()?;
360+
let expr = match first_arg {
361+
argument::Argument::Positional(p) => p.value,
362+
argument::Argument::Named(n) => n.value,
363+
};
364+
365+
let elements: Box<dyn Iterator<Item = &ArrayElement<'_>>> = match expr {
366+
Expression::Array(arr) => Box::new(arr.elements.iter()),
367+
Expression::LegacyArray(arr) => Box::new(arr.elements.iter()),
368+
_ => return None,
369+
};
370+
371+
let mut entries = Vec::new();
372+
for elem in elements {
373+
if let ArrayElement::KeyValue(kv) = elem {
374+
let key = extract_string_literal_value(kv.key, ctx.content)?;
375+
let value_str = extract_string_literal_value(kv.value, ctx.content)?;
376+
let value_type = PhpType::parse(&value_str);
377+
entries.push(ShapeEntry {
378+
key: Some(key),
379+
value_type,
380+
optional: false,
381+
});
382+
}
383+
}
384+
385+
if !entries.is_empty() {
386+
return Some(PhpType::ArrayShape(entries));
387+
}
388+
}
389+
}
390+
391+
None
392+
}
393+
394+
/// Replace bare `array` components in `ty` with the shape from an
395+
/// `#[ArrayShape]` attribute, if present. Handles `array`, `?array`,
396+
/// and `array|false` patterns.
397+
pub(crate) fn apply_array_shape_override(
398+
ty: PhpType,
399+
attribute_lists: &Sequence<'_, attribute::AttributeList<'_>>,
400+
ctx: &DocblockCtx<'_>,
401+
) -> PhpType {
402+
let Some(shape) = extract_array_shape_type(attribute_lists, ctx) else {
403+
return ty;
404+
};
405+
406+
match &ty {
407+
// Exact `array` → replace with shape.
408+
PhpType::Named(n) if n == "array" => shape,
409+
// `?array` → `?array{...}`
410+
PhpType::Nullable(inner) => {
411+
if matches!(inner.as_ref(), PhpType::Named(n) if n == "array") {
412+
PhpType::Nullable(Box::new(shape))
413+
} else {
414+
ty
415+
}
416+
}
417+
// `array|false` or other unions containing bare `array`.
418+
PhpType::Union(members) => {
419+
let new_members: Vec<PhpType> = members
420+
.iter()
421+
.map(|m| {
422+
if matches!(m, PhpType::Named(n) if n == "array") {
423+
shape.clone()
424+
} else {
425+
m.clone()
426+
}
427+
})
428+
.collect();
429+
PhpType::Union(new_members)
430+
}
431+
_ => ty,
432+
}
433+
}
434+
328435
/// Parse the version → type array inside `LanguageLevelTypeAware`.
329436
///
330437
/// Handles both `['8.0' => 'string']` (short array) and

src/php_type.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1438,6 +1438,10 @@ impl PhpType {
14381438
match self {
14391439
PhpType::ArrayShape(entries) | PhpType::ObjectShape(entries) => Some(entries),
14401440
PhpType::Nullable(inner) => inner.shape_entries(),
1441+
PhpType::Union(members) => {
1442+
// Find the first array/object shape member in the union.
1443+
members.iter().find_map(|m| m.shape_entries())
1444+
}
14411445
_ => None,
14421446
}
14431447
}

0 commit comments

Comments
 (0)