Skip to content

Commit e5d9ffd

Browse files
committed
Fix some consistency issues between tools
1 parent 5bf299b commit e5d9ffd

4 files changed

Lines changed: 103 additions & 20 deletions

File tree

docs/todo.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ unlikely to move the needle for most users.
9999

100100
| # | Item | Impact | Effort |
101101
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- | ----------- |
102+
| | **[Bug Fixes](todo/bugs.md)** | | |
103+
| B1 | [Dual type-resolution engines cause hover / completion divergence](todo/bugs.md#b1-dual-type-resolution-engines-cause-hover--completion-divergence) | Medium | Medium-High |
102104
| | **[Completion](todo/completion.md)** | | |
103105
| C1 | Array functions needing new code paths | Medium | High |
104106
| C9 | [Lazy documentation via `completionItem/resolve`](todo/completion.md#c9-lazy-documentation-via-completionitemresolve) | Medium | Medium |

docs/todo/bugs.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,68 @@ within the same impact tier.
1515

1616
---
1717

18+
#### B1. Dual type-resolution engines cause hover / completion divergence
19+
20+
| | |
21+
|---|---|
22+
| **Impact** | Medium |
23+
| **Effort** | Medium-High |
24+
25+
Variable type resolution has two parallel RHS expression resolvers that
26+
must be kept in sync manually:
27+
28+
1. **`resolve_rhs_expression`** in `completion/variable/rhs_resolution.rs`
29+
— returns `Vec<ClassInfo>`, used by the completion pipeline.
30+
2. **`resolve_rhs_raw_type`** in `completion/variable/raw_type_inference.rs`
31+
— returns `Option<String>`, used by hover's type-string path
32+
(`resolve_variable_type_string` → step 5).
33+
34+
Hover tries the type-string path first. When it returns `Some(…)` the
35+
ClassInfo fallback never fires, so hover shows whatever the raw-type
36+
engine inferred — even if it is wrong. The completion pipeline uses the
37+
ClassInfo engine directly and gets the correct answer.
38+
39+
The two resolvers handle **different sets of expression types**:
40+
41+
| Expression kind | ClassInfo engine | Raw-type engine |
42+
| ------------------------ | :--------------: | :-------------: |
43+
| `clone` |||
44+
| `\|>` (pipe) |||
45+
| `Closure` / arrow fn | ✓ (→ `\Closure`) ||
46+
| `yield` (send type) |||
47+
| `Call` (full resolution) || partial¹ |
48+
| `Access` (property) || partial¹ |
49+
| Scalar literals |||
50+
| Array literal inference |||
51+
52+
¹ The raw-type engine's `_ =>` catch-all delegates to
53+
`extract_rhs_iterable_raw_type`, which covers some calls and accesses
54+
but not all.
55+
56+
The `??` null-coalesce handling also diverges: the ClassInfo engine
57+
checks whether the LHS *AST node* is syntactically non-nullable (pattern
58+
match on `Clone`, `Literal`, etc.), while the raw-type engine checks
59+
whether the resolved *type string* is non-nullable. When the raw-type
60+
engine cannot resolve the LHS at all (e.g. `clone $x``None`), it
61+
falls through to returning only the RHS type, which is how
62+
`clone $pen ?? new Marker()` shows `Marker` on hover but `Pen` on
63+
completion.
64+
65+
**Possible approaches:**
66+
67+
- **Unify into one engine** — make `resolve_rhs_expression` the single
68+
source of truth and derive the type string from its result. Hover
69+
would call the ClassInfo path and format the names into a union
70+
string, falling back to `resolve_variable_type_string` only for
71+
scalar / generic types that ClassInfo cannot represent. This
72+
eliminates the synchronisation burden entirely.
73+
- **Exhaustiveness enforcement** — if keeping both engines, add a shared
74+
enum of "RHS expression kinds" and a compile-time check (or test)
75+
that both match arms cover the same set, so new expression types
76+
cannot be added to one without the other.
77+
78+
---
79+
1880
#### B12. Interface-extends-interface constants (and other members) not merged
1981

2082
| | |

src/code_actions/update_docblock.rs

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use crate::Backend;
2525
use crate::completion::phpdoc::generation::enrichment_plain;
2626
use crate::completion::source::throws_analysis::{self, ThrowsContext};
2727
use crate::docblock::is_compatible_refinement;
28-
use crate::docblock::type_strings::split_type_token;
28+
use crate::docblock::type_strings::{split_type_token, split_union_depth0};
2929
use crate::types::{ClassInfo, FunctionLoader};
3030
use crate::util::offset_to_position;
3131

@@ -785,8 +785,8 @@ fn is_type_contradiction(doc_type: &str, native_type: &str) -> bool {
785785
// If the native type contains `|`, compare the union components.
786786

787787
// Simple heuristic: normalize both and compare base types.
788-
let doc_bases = split_union_types(&doc_clean);
789-
let native_bases = split_union_types(&native_clean);
788+
let doc_bases = split_union_depth0(&doc_clean);
789+
let native_bases = split_union_depth0(&native_clean);
790790

791791
// If every native base appears in doc bases (or a refinement thereof),
792792
// it's not a contradiction.
@@ -812,18 +812,12 @@ fn normalize_type_for_comparison(t: &str) -> String {
812812
|rest| format!("{}|null", rest.to_lowercase()),
813813
);
814814
// Sort union components.
815-
let mut parts: Vec<&str> = t.split('|').map(|s| s.trim()).collect();
815+
let mut parts: Vec<&str> = split_union_depth0(&t).into_iter().map(|s| s.trim()).collect();
816816
parts.sort();
817817
parts.join("|")
818818
}
819819

820-
/// Split a union type string into its components.
821-
fn split_union_types(t: &str) -> Vec<String> {
822-
t.split('|')
823-
.map(|s| s.trim().to_string())
824-
.filter(|s| !s.is_empty())
825-
.collect()
826-
}
820+
827821

828822
/// Build the updated docblock text.
829823
fn build_updated_docblock(

src/diagnostics/deprecated.rs

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ impl Backend {
113113
&file_use_map,
114114
&file_namespace,
115115
&local_classes,
116+
span.start,
116117
)
117118
.and_then(|name| self.find_or_load_class(&name))
118119
.map(|arc| ClassInfo::clone(&arc));
@@ -334,23 +335,35 @@ fn resolve_subject_to_class_name(
334335
file_use_map: &HashMap<String, String>,
335336
file_namespace: &Option<String>,
336337
local_classes: &[Arc<ClassInfo>],
338+
access_offset: u32,
337339
) -> Option<String> {
338340
let trimmed = subject_text.trim();
339341

340342
match trimmed {
341343
"self" | "static" => {
342344
// Find the enclosing class in this file
343-
find_enclosing_class_fqn(local_classes, file_namespace)
345+
find_enclosing_class_fqn(local_classes, file_namespace, access_offset)
344346
}
345347
"parent" => {
346348
// Find the enclosing class that actually has a parent.
347-
// Prefer a class with `parent_class` set — that's the one
348-
// where `parent::` is meaningful. Fall back to the first
349-
// non-anonymous class if none has a parent (shouldn't happen
350-
// in valid code, but be defensive).
349+
// Prefer a class whose offset range contains the access site
350+
// and that has `parent_class` set — that's the one where
351+
// `parent::` is meaningful. Fall back to any non-anonymous
352+
// class with a parent, then to the first non-anonymous class
353+
// (shouldn't happen in valid code, but be defensive).
351354
let cls = local_classes
352355
.iter()
353-
.find(|c| !c.name.starts_with("__anonymous@") && c.parent_class.is_some())
356+
.find(|c| {
357+
!c.name.starts_with("__anonymous@")
358+
&& c.parent_class.is_some()
359+
&& access_offset >= c.start_offset
360+
&& access_offset <= c.end_offset
361+
})
362+
.or_else(|| {
363+
local_classes
364+
.iter()
365+
.find(|c| !c.name.starts_with("__anonymous@") && c.parent_class.is_some())
366+
})
354367
.or_else(|| {
355368
local_classes
356369
.iter()
@@ -362,7 +375,7 @@ fn resolve_subject_to_class_name(
362375
.map(|p| resolve_to_fqn(p, file_use_map, file_namespace))
363376
})
364377
}
365-
"$this" => find_enclosing_class_fqn(local_classes, file_namespace),
378+
"$this" => find_enclosing_class_fqn(local_classes, file_namespace, access_offset),
366379
_ if is_static && !trimmed.starts_with('$') => {
367380
// Static access on a class name: `ClassName::method()`
368381
Some(resolve_to_fqn(trimmed, file_use_map, file_namespace))
@@ -425,11 +438,23 @@ fn resolve_variable_subject(
425438
fn find_enclosing_class_fqn(
426439
local_classes: &[Arc<ClassInfo>],
427440
file_namespace: &Option<String>,
441+
offset: u32,
428442
) -> Option<String> {
429-
// Skip anonymous classes
443+
// Find the non-anonymous class whose byte range contains the offset.
444+
// Fall back to the first non-anonymous class for top-level code
445+
// outside any class body.
430446
let cls = local_classes
431447
.iter()
432-
.find(|c| !c.name.starts_with("__anonymous@"))?;
448+
.find(|c| {
449+
!c.name.starts_with("__anonymous@")
450+
&& offset >= c.start_offset
451+
&& offset <= c.end_offset
452+
})
453+
.or_else(|| {
454+
local_classes
455+
.iter()
456+
.find(|c| !c.name.starts_with("__anonymous@"))
457+
})?;
433458
if let Some(ns) = file_namespace {
434459
Some(format!("{}\\{}", ns, cls.name))
435460
} else {

0 commit comments

Comments
 (0)