Skip to content

Commit dd2265d

Browse files
committed
Migrate bracket-access segment walks to use PhpType throughout
1 parent a354629 commit dd2265d

5 files changed

Lines changed: 109 additions & 131 deletions

File tree

docs/todo.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ within the same impact tier.
2323

2424
| # | Item | Impact | Effort |
2525
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------- |
26-
| T26 | [Carry `PhpType` through bracket-access segment walks](todo/type-inference.md#t26-carry-phptype-through-bracket-access-segment-walks) | High | Low |
2726
| T27 | [Keep substituted return types as `PhpType` in RHS resolution](todo/type-inference.md#t27-keep-substituted-return-types-as-phptype-in-rhs-resolution) | High | Low |
2827
| T28 | [Accept `&[PhpType]` in generic-arg resolution](todo/type-inference.md#t28-accept-phptype-in-generic-arg-resolution) | Medium | Medium |
2928
| T29 | [Migrate `type_strings_joined` call sites to `types_joined`](todo/type-inference.md#t29-migrate-type_strings_joined-call-sites-to-types_joined) | Medium | Low |
@@ -100,7 +99,7 @@ unlikely to move the needle for most users.
10099
| C10 | [Deprecation markers on class-name completions from all sources](todo/completion.md#c10-deprecation-markers-on-class-name-completions-from-all-sources) | Low | Low |
101100
| | **[Type Inference](todo/type-inference.md)** | | |
102101
| T25 | [Forward-walking scope model](todo/type-inference.md#t25-forward-walking-scope-model-for-variable-type-resolution) (eliminate backward-scanning depth limit) | High | Very High |
103-
| T19 | [Structured type representation](todo/type-inference.md#t19-structured-type-representation) (partially complete; remaining API boundary cleanup tracked as T26–T30) | High | Very High |
102+
| T19 | [Structured type representation](todo/type-inference.md#t19-structured-type-representation) (partially complete; remaining API boundary cleanup tracked as T27–T30) | High | Very High |
104103
| T20 | [Type narrowing reconciliation engine](todo/type-inference.md#t20-type-narrowing-reconciliation-engine) (sure/sureNot tracking, AND/OR algebra) | Medium-High | High |
105104
| T6 | `Closure::bind()` / `Closure::fromCallable()` return type preservation | Low-Medium | Low-Medium |
106105
| T12 | [Intersection types flattened to unions by `type_strings_joined`](todo/type-inference.md#t12-intersection-types-flattened-to-unions-by-type_strings_joined) (subsumed by T29) | Low-Medium | Low |

docs/todo/type-inference.md

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ The remaining work is eliminating `PhpType -> String -> PhpType`
428428
round-trips at internal API boundaries. Many functions still accept
429429
`&str` or return `Option<String>` for types, forcing callers to
430430
stringify and downstream consumers to re-parse. The concrete migration
431-
tasks are tracked as T26 through T30.
431+
tasks are tracked as T27 through T30.
432432

433433
Subtype checking (`is Cat a subtype of Animal?`), union simplification
434434
(`string|string`, `true|false -> bool`), and intersection distribution
@@ -594,40 +594,6 @@ progresses.
594594

595595
---
596596

597-
## T26. Carry `PhpType` through bracket-access segment walks
598-
**Impact: High · Effort: Low**
599-
600-
In `rhs_resolution.rs` (`resolve_rhs_array_access`, ~L885-915), the
601-
bracket-access segment walk carries `current_type` as a `String`. On
602-
every iteration it re-parses to `PhpType`, calls `.shape_value_type()`
603-
or `.extract_value_type()`, then stringifies the result to continue.
604-
For chained access like `$result['items'][0]['name']` this produces a
605-
`PhpType -> String -> PhpType` round-trip per bracket level.
606-
607-
A similar pattern exists in `resolver.rs`
608-
(`try_chained_array_access_with_candidates`) where candidates arrive
609-
as `String` even though callers already have `PhpType` values.
610-
611-
**What to change:**
612-
613-
1. In `resolve_rhs_array_access`, change `current_type` from `String`
614-
to `PhpType`. Call `.shape_value_type()` / `.extract_value_type()`
615-
directly and assign the result without stringifying. Only convert
616-
to string at the end for the final `type_hint_to_classes` call.
617-
618-
2. In `resolver.rs`, change `try_chained_array_access_with_candidates`
619-
to accept `PhpType` candidates instead of `String`. Update the
620-
callers (`resolve_target_classes_expr` ArrayAccess branch,
621-
`resolve_property_type_hint` call site) to pass `PhpType` directly
622-
instead of stringifying.
623-
624-
**Files:** `src/completion/variable/rhs_resolution.rs`,
625-
`src/completion/resolver.rs`.
626-
627-
**Part of:** T19 (structured type representation migration).
628-
629-
---
630-
631597
## T27. Keep substituted return types as `PhpType` in RHS resolution
632598
**Impact: High · Effort: Low**
633599

src/completion/resolver.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -801,7 +801,7 @@ pub(crate) fn resolve_target_classes_expr(
801801
if let SubjectExpr::CallExpr { callee, args_text } = base.as_ref() {
802802
let call_raw = resolve_call_raw_return_type(callee, args_text, ctx);
803803
if let Some(raw) = call_raw {
804-
let candidates = std::iter::once(raw);
804+
let candidates = std::iter::once(PhpType::parse(&raw));
805805
if let Some(resolved) =
806806
super::source::helpers::try_chained_array_access_with_candidates(
807807
candidates,
@@ -834,7 +834,7 @@ pub(crate) fn resolve_target_classes_expr(
834834
// parameters like `array<string, IntCollection>` or
835835
// `Collection<int, Translation>` that would be lost if
836836
// we resolved through `type_hint_to_classes` first.
837-
let property_raw_type = if let SubjectExpr::PropertyChain {
837+
let property_raw_type: Option<PhpType> = if let SubjectExpr::PropertyChain {
838838
base: prop_base,
839839
property,
840840
} = base.as_ref()
@@ -843,18 +843,18 @@ pub(crate) fn resolve_target_classes_expr(
843843
resolved_to_arcs(resolve_target_classes_expr(prop_base, access_kind, ctx));
844844
owner_arcs.iter().find_map(|cls| {
845845
crate::inheritance::resolve_property_type_hint(cls, property, class_loader)
846-
.map(|ty| ty.to_string())
847846
})
848847
} else {
849848
None
850849
};
851850

852-
let docblock_type = docblock::find_iterable_raw_type_in_source(
851+
let docblock_type: Option<PhpType> = docblock::find_iterable_raw_type_in_source(
853852
ctx.content,
854853
ctx.cursor_offset as usize,
855854
&base_var,
856-
);
857-
let ast_type = {
855+
)
856+
.map(|s| PhpType::parse(&s));
857+
let ast_type: Option<PhpType> = {
858858
let dummy_class;
859859
let effective_class = match current_class {
860860
Some(cc) => cc,
@@ -875,7 +875,7 @@ pub(crate) fn resolve_target_classes_expr(
875875
if resolved.is_empty() {
876876
None
877877
} else {
878-
Some(ResolvedType::type_strings_joined(&resolved))
878+
Some(ResolvedType::types_joined(&resolved))
879879
}
880880
};
881881

src/completion/source/helpers.rs

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
/// `$fn = strlen(...)` or `$fn = $obj->method(...)`.
1818
/// - **`try_chained_array_access_with_candidates`** /
1919
/// **`walk_array_segments_and_resolve`** — walk bracket segments on
20-
/// candidate raw type strings to resolve array access chains.
20+
/// candidate `PhpType` values to resolve array access chains.
2121
///
2222
/// All functions in this module are free functions (not methods on
2323
/// `Backend`). Cross-module dependencies that previously used `Self::`
@@ -485,23 +485,23 @@ pub(in crate::completion) fn extract_first_class_callable_return_type(
485485
/// Resolve a chained array access, trying each candidate raw type
486486
/// in order until one succeeds through the full segment walk.
487487
///
488-
/// Each candidate raw type string is fed through
488+
/// Each candidate `PhpType` is fed through
489489
/// `walk_array_segments_and_resolve`. The first that resolves
490490
/// through the segment walk and, if it produces a non-empty
491491
/// `ClassInfo` set, returned immediately. Returns `None` when no
492492
/// candidate succeeds.
493493
pub(in crate::completion) fn try_chained_array_access_with_candidates<'a>(
494-
candidates: impl Iterator<Item = String> + 'a,
494+
candidates: impl Iterator<Item = PhpType> + 'a,
495495
segments: &[BracketSegment],
496496
current_class: Option<&ClassInfo>,
497497
all_classes: &[Arc<ClassInfo>],
498498
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
499499
) -> Option<Vec<ClassInfo>> {
500500
let current_class_name = current_class.map(|c| c.name.as_str()).unwrap_or("");
501501

502-
for raw_type in candidates {
502+
for candidate in candidates {
503503
if let Some(result) = walk_array_segments_and_resolve(
504-
&raw_type,
504+
&candidate,
505505
segments,
506506
current_class_name,
507507
all_classes,
@@ -514,35 +514,38 @@ pub(in crate::completion) fn try_chained_array_access_with_candidates<'a>(
514514
None
515515
}
516516

517-
/// Walk bracket segments on a raw type string, then resolve the
518-
/// resulting type to `ClassInfo`.
517+
/// Walk bracket segments on a `PhpType`, then resolve the resulting
518+
/// type to `ClassInfo`.
519519
///
520520
/// Returns `Some(classes)` when the full segment chain resolves
521521
/// successfully, or `None` when a segment cannot be applied (e.g.
522522
/// the array shape does not contain the requested key).
523523
fn walk_array_segments_and_resolve(
524-
raw_type: &str,
524+
base_type: &PhpType,
525525
segments: &[BracketSegment],
526526
current_class_name: &str,
527527
all_classes: &[Arc<ClassInfo>],
528528
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
529529
) -> Option<Vec<ClassInfo>> {
530-
let mut current_str = raw_type.to_string();
531-
532530
// Expand type aliases before walking segments. The raw type may
533531
// be an alias name like `UserData` that resolves to
534532
// `array{name: string, pen: Pen}`. Without expansion the
535533
// segment walk would fail to extract shape values.
536-
if let Some(expanded) = crate::completion::type_resolution::resolve_type_alias(
537-
&current_str,
538-
current_class_name,
539-
all_classes,
540-
class_loader,
541-
) {
542-
current_str = expanded;
543-
}
544-
545-
let mut current = PhpType::parse(&current_str);
534+
let mut current = if let PhpType::Named(_) = base_type {
535+
let name_str = base_type.to_string();
536+
if let Some(expanded) = crate::completion::type_resolution::resolve_type_alias(
537+
&name_str,
538+
current_class_name,
539+
all_classes,
540+
class_loader,
541+
) {
542+
PhpType::parse(&expanded)
543+
} else {
544+
base_type.clone()
545+
}
546+
} else {
547+
base_type.clone()
548+
};
546549

547550
for seg in segments {
548551
// Try pure-type extraction first (array shapes, generics).

src/completion/variable/rhs_resolution.rs

Lines changed: 77 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -840,108 +840,118 @@ fn resolve_rhs_array_access<'b>(
840840
// Resolve the base expression's raw type string.
841841
// For bare variables (`$var['key']`), use docblock or assignment scanning.
842842
// For property chains (`$obj->prop['key']`), resolve the property type.
843-
let raw_type = if let Expression::Variable(Variable::Direct(base_dv)) = current_expr {
844-
let base_var = base_dv.name.to_string();
845-
docblock::find_iterable_raw_type_in_source(ctx.content, access_offset, &base_var).or_else(
846-
|| {
847-
let resolved = super::resolution::resolve_variable_types(
848-
&base_var,
849-
ctx.current_class,
850-
ctx.all_classes,
851-
ctx.content,
852-
access_offset as u32,
853-
ctx.class_loader,
854-
Loaders::with_function(ctx.function_loader()),
855-
);
856-
if resolved.is_empty() {
857-
None
858-
} else {
859-
Some(ResolvedType::type_strings_joined(&resolved))
860-
}
861-
},
862-
)
863-
} else {
864-
// Non-variable base (e.g. property access `$obj->prop['key']`,
865-
// method call `$obj->getItems()['key']`, etc.).
866-
// Resolve the base expression to get its type string.
867-
let base_resolved = resolve_rhs_expression(current_expr, ctx);
868-
if base_resolved.is_empty() {
869-
None
843+
let raw_type: Option<PhpType> =
844+
if let Expression::Variable(Variable::Direct(base_dv)) = current_expr {
845+
let base_var = base_dv.name.to_string();
846+
docblock::find_iterable_raw_type_in_source(ctx.content, access_offset, &base_var)
847+
.map(|s| PhpType::parse(&s))
848+
.or_else(|| {
849+
let resolved = super::resolution::resolve_variable_types(
850+
&base_var,
851+
ctx.current_class,
852+
ctx.all_classes,
853+
ctx.content,
854+
access_offset as u32,
855+
ctx.class_loader,
856+
Loaders::with_function(ctx.function_loader()),
857+
);
858+
if resolved.is_empty() {
859+
None
860+
} else {
861+
Some(ResolvedType::types_joined(&resolved))
862+
}
863+
})
870864
} else {
871-
Some(ResolvedType::type_strings_joined(&base_resolved))
872-
}
873-
};
865+
// Non-variable base (e.g. property access `$obj->prop['key']`,
866+
// method call `$obj->getItems()['key']`, etc.).
867+
// Resolve the base expression to get its type.
868+
let base_resolved = resolve_rhs_expression(current_expr, ctx);
869+
if base_resolved.is_empty() {
870+
None
871+
} else {
872+
Some(ResolvedType::types_joined(&base_resolved))
873+
}
874+
};
874875

875-
let Some(mut current_type) = raw_type else {
876+
let Some(mut current) = raw_type else {
876877
return vec![];
877878
};
878879

879880
// Expand type aliases so that shape/generic extraction can see the
880881
// underlying type (e.g. a `@phpstan-type` alias).
882+
let current_str = current.to_string();
881883
if let Some(expanded) = crate::completion::type_resolution::resolve_type_alias(
882-
&current_type,
884+
&current_str,
883885
&ctx.current_class.name,
884886
ctx.all_classes,
885887
ctx.class_loader,
886888
) {
887-
current_type = expanded;
889+
current = PhpType::parse(&expanded);
888890
}
889891

890892
// Walk each bracket segment, narrowing the type at each step.
891893
for seg in &segments {
892-
let parsed = crate::php_type::PhpType::parse(&current_type);
893-
894894
// Try pure-type extraction first (array shapes, generics).
895895
let extracted = match seg {
896-
ArrayBracketSegment::StringKey(key) => parsed
896+
ArrayBracketSegment::StringKey(key) => current
897897
.shape_value_type(key)
898-
.map(|t| t.to_string())
899-
.or_else(|| parsed.extract_value_type(true).map(|t| t.to_string())),
900-
ArrayBracketSegment::ElementAccess => {
901-
parsed.extract_value_type(true).map(|t| t.to_string())
902-
}
898+
.cloned()
899+
.or_else(|| current.extract_value_type(true).cloned()),
900+
ArrayBracketSegment::ElementAccess => current.extract_value_type(true).cloned(),
903901
};
904902

905903
if let Some(element) = extracted {
906-
current_type = element;
907-
continue;
904+
current = element;
905+
} else {
906+
// Fallback: when the current type is a plain class name (e.g.
907+
// `OpeningHours`), resolve the class and check its iterable
908+
// generics (`@extends`, `@implements`) for the element type.
909+
// This handles `$obj->prop['key']` where `prop` is a collection
910+
// class like `OpeningHours extends DataCollection<string, Day>`.
911+
let type_str = current.to_string();
912+
let class_element = crate::completion::type_resolution::type_hint_to_classes(
913+
&type_str,
914+
&ctx.current_class.name,
915+
ctx.all_classes,
916+
ctx.class_loader,
917+
)
918+
.into_iter()
919+
.find_map(|cls| {
920+
let merged = crate::virtual_members::resolve_class_fully(&cls, ctx.class_loader);
921+
super::foreach_resolution::extract_iterable_element_type_from_class(
922+
&merged,
923+
ctx.class_loader,
924+
)
925+
});
926+
927+
if let Some(element) = class_element {
928+
current = PhpType::parse(&element);
929+
} else {
930+
return vec![];
931+
}
908932
}
909933

910-
// Fallback: when the current type is a plain class name (e.g.
911-
// `OpeningHours`), resolve the class and check its iterable
912-
// generics (`@extends`, `@implements`) for the element type.
913-
// This handles `$obj->prop['key']` where `prop` is a collection
914-
// class like `OpeningHours extends DataCollection<string, Day>`.
915-
let class_element = crate::completion::type_resolution::type_hint_to_classes(
916-
&current_type,
934+
// After each segment, the resulting type might itself be an
935+
// alias (e.g. a shape value defined as another alias).
936+
let seg_str = current.to_string();
937+
if let Some(expanded) = crate::completion::type_resolution::resolve_type_alias(
938+
&seg_str,
917939
&ctx.current_class.name,
918940
ctx.all_classes,
919941
ctx.class_loader,
920-
)
921-
.into_iter()
922-
.find_map(|cls| {
923-
let merged = crate::virtual_members::resolve_class_fully(&cls, ctx.class_loader);
924-
super::foreach_resolution::extract_iterable_element_type_from_class(
925-
&merged,
926-
ctx.class_loader,
927-
)
928-
});
929-
930-
if let Some(element) = class_element {
931-
current_type = element;
932-
} else {
933-
return vec![];
942+
) {
943+
current = PhpType::parse(&expanded);
934944
}
935945
}
936946

937947
ResolvedType::from_classes_with_hint(
938-
crate::completion::type_resolution::type_hint_to_classes(
939-
&current_type,
948+
crate::completion::type_resolution::type_hint_to_classes_typed(
949+
&current,
940950
&ctx.current_class.name,
941951
ctx.all_classes,
942952
ctx.class_loader,
943953
),
944-
PhpType::parse(&current_type),
954+
current,
945955
)
946956
}
947957

0 commit comments

Comments
 (0)