Skip to content

Commit 9e22d06

Browse files
committed
Unify to_subject_text methods
1 parent 1d3d6de commit 9e22d06

2 files changed

Lines changed: 160 additions & 118 deletions

File tree

docs/todo/refactor.md

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -416,24 +416,6 @@ constructor.
416416

417417
---
418418

419-
## Unify `expr_to_subject_text` with `SubjectExpr::to_subject_text`
420-
421-
**What to do.** `extraction/subject_text.rs`'s `expr_to_subject_text`
422-
(~202 lines matching ~30 expression variants) duplicates
423-
`subject_expr.rs::SubjectExpr::to_subject_text`. Unify: build a
424-
`SubjectExpr` from the AST and render it, instead of a second
425-
serializer. This is a semantic change to the shared subject-text
426-
pipeline (`SubjectExpr::parse` consumes these strings downstream), so
427-
it needs before/after `analyze` comparison on the test projects to
428-
guard against resolution regressions in array-element, null-safe, and
429-
call-argument serialization.
430-
431-
**Why it matters.** Two serializers for the same subject-text concept
432-
drift independently; a fix applied to one (e.g. a missing expression
433-
variant) silently leaves the other broken.
434-
435-
---
436-
437419
## Code actions: shared edit-building helpers
438420

439421
**What to do.** Complementary to (and independent of) A34 in the

src/symbol_map/extraction/subject_text.rs

Lines changed: 160 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -2,106 +2,123 @@ use mago_syntax::cst::sequence::TokenSeparatedSequence;
22
use mago_syntax::cst::*;
33

44
use super::*;
5+
use crate::subject_expr::{BracketSegment, SubjectExpr};
56

67
// ─── Expression to subject text ─────────────────────────────────────────────
78

89
/// Convert an AST expression to the subject text string that
910
/// `resolve_target_classes` expects.
11+
///
12+
/// This is a thin renderer over [`expr_to_subject_expr`]: the AST is first
13+
/// lowered into a structured [`SubjectExpr`], which is then serialised by
14+
/// [`SubjectExpr::to_subject_text`]. Keeping a single serialiser (the one on
15+
/// `SubjectExpr`) prevents this AST path and the string-parse path
16+
/// (`SubjectExpr::parse`) from drifting apart. Expressions with no subject
17+
/// representation (dynamic member names on nested chains, unsupported forms)
18+
/// lower to `None` and render as the empty string.
1019
pub(super) fn expr_to_subject_text(expr: &Expression<'_>) -> String {
11-
match expr {
12-
Expression::Variable(Variable::Direct(dv)) => bytes_to_str(dv.name).to_string(),
13-
Expression::Self_(_) => "self".to_string(),
14-
Expression::Static(_) => "static".to_string(),
15-
Expression::Parent(_) => "parent".to_string(),
16-
Expression::Identifier(ident) => bytes_to_str(ident.value()).to_string(),
20+
expr_to_subject_expr(expr)
21+
.map(|se| se.to_subject_text())
22+
.unwrap_or_default()
23+
}
1724

18-
Expression::Access(Access::Property(pa)) => {
19-
let obj = expr_to_subject_text(pa.object);
20-
if let ClassLikeMemberSelector::Identifier(ident) = &pa.property {
21-
format!("{}->{}", obj, bytes_to_str(ident.value))
22-
} else {
23-
obj
24-
}
25-
}
26-
Expression::Access(Access::NullSafeProperty(pa)) => {
27-
let obj = expr_to_subject_text(pa.object);
28-
if let ClassLikeMemberSelector::Identifier(ident) = &pa.property {
29-
format!("{}?->{}", obj, bytes_to_str(ident.value))
30-
} else {
31-
obj
32-
}
25+
/// Lower an AST expression into a structured [`SubjectExpr`].
26+
///
27+
/// Returns `None` when the expression has no subject-text representation
28+
/// (e.g. it is a form the type engine cannot resolve). Call arguments are
29+
/// serialised eagerly via [`format_all_call_args`] and stored as the raw
30+
/// `args_text` of the resulting [`SubjectExpr::CallExpr`].
31+
///
32+
/// Note on null-safe access: `SubjectExpr` does not distinguish `?->` from
33+
/// `->` (`SubjectExpr::parse` strips the `?` and the resolver normalises the
34+
/// two), so null-safe property and method access lower to the same
35+
/// `PropertyChain` / `MethodCall` shapes as their plain counterparts.
36+
pub(super) fn expr_to_subject_expr(expr: &Expression<'_>) -> Option<SubjectExpr> {
37+
match expr {
38+
Expression::Variable(Variable::Direct(dv)) => {
39+
Some(SubjectExpr::Variable(bytes_to_str(dv.name).to_string()))
3340
}
41+
Expression::Self_(_) => Some(SubjectExpr::SelfKw),
42+
Expression::Static(_) => Some(SubjectExpr::StaticKw),
43+
Expression::Parent(_) => Some(SubjectExpr::Parent),
44+
Expression::Identifier(ident) => Some(SubjectExpr::ClassName(
45+
bytes_to_str(ident.value()).to_string(),
46+
)),
47+
48+
// Property access (plain `->` and null-safe `?->` share a shape).
49+
Expression::Access(Access::Property(pa)) => lower_property(pa.object, &pa.property),
50+
Expression::Access(Access::NullSafeProperty(pa)) => lower_property(pa.object, &pa.property),
3451
Expression::Access(Access::StaticProperty(spa)) => {
35-
let class_text = expr_to_subject_text(spa.class);
52+
let class = expr_to_subject_expr(spa.class)?;
3653
if let Variable::Direct(dv) = &spa.property {
37-
format!("{}::{}", class_text, bytes_to_str(dv.name))
54+
Some(SubjectExpr::StaticAccess {
55+
class: class.to_subject_text(),
56+
member: bytes_to_str(dv.name).to_string(),
57+
})
3858
} else {
39-
class_text
59+
Some(class)
4060
}
4161
}
4262
Expression::Access(Access::ClassConstant(cca)) => {
43-
let class_text = expr_to_subject_text(cca.class);
63+
let class = expr_to_subject_expr(cca.class)?;
4464
match &cca.constant {
45-
ClassLikeConstantSelector::Identifier(ident) => {
46-
format!("{}::{}", class_text, bytes_to_str(ident.value))
47-
}
48-
_ => class_text,
65+
ClassLikeConstantSelector::Identifier(ident) => Some(SubjectExpr::StaticAccess {
66+
class: class.to_subject_text(),
67+
member: bytes_to_str(ident.value).to_string(),
68+
}),
69+
_ => Some(class),
4970
}
5071
}
5172

73+
// Instance method call (plain `->` and null-safe `?->` share a
74+
// shape). A dynamic method name (`$obj->$name()`) has no identifier,
75+
// so the method lowers to `?` — the type engine cannot resolve it
76+
// either way.
5277
Expression::Call(Call::Method(mc)) => {
53-
let obj = expr_to_subject_text(mc.object);
54-
if let ClassLikeMemberSelector::Identifier(ident) = &mc.method {
55-
let args_text = format_all_call_args(&mc.argument_list.arguments);
56-
format!("{}->{}({})", obj, bytes_to_str(ident.value), args_text)
57-
} else {
58-
format!("{}->?()", obj)
59-
}
78+
lower_method_call(mc.object, &mc.method, &mc.argument_list.arguments)
6079
}
6180
Expression::Call(Call::NullSafeMethod(mc)) => {
62-
let obj = expr_to_subject_text(mc.object);
63-
if let ClassLikeMemberSelector::Identifier(ident) = &mc.method {
64-
let args_text = format_all_call_args(&mc.argument_list.arguments);
65-
format!("{}?->{}({})", obj, bytes_to_str(ident.value), args_text)
66-
} else {
67-
format!("{}?->?()", obj)
68-
}
81+
lower_method_call(mc.object, &mc.method, &mc.argument_list.arguments)
6982
}
7083
Expression::Call(Call::StaticMethod(sc)) => {
71-
let class_text = expr_to_subject_text(sc.class);
72-
if let ClassLikeMemberSelector::Identifier(ident) = &sc.method {
73-
let args_text = format_all_call_args(&sc.argument_list.arguments);
74-
format!(
75-
"{}::{}({})",
76-
class_text,
77-
bytes_to_str(ident.value),
78-
args_text
79-
)
80-
} else {
81-
format!("{}::?()", class_text)
82-
}
84+
let class = expr_to_subject_expr(sc.class)?;
85+
let (method, args_text) = match &sc.method {
86+
ClassLikeMemberSelector::Identifier(ident) => (
87+
bytes_to_str(ident.value).to_string(),
88+
format_all_call_args(&sc.argument_list.arguments),
89+
),
90+
_ => ("?".to_string(), String::new()),
91+
};
92+
Some(SubjectExpr::CallExpr {
93+
callee: Box::new(SubjectExpr::StaticMethodCall {
94+
class: class.to_subject_text(),
95+
method,
96+
}),
97+
args_text,
98+
})
8399
}
84100
Expression::Call(Call::Function(fc)) => {
85-
let func_text = expr_to_subject_text(fc.function);
101+
let callee = expr_to_subject_expr(fc.function)?;
86102
let args_text = format_all_call_args(&fc.argument_list.arguments);
87-
// When the callee is a parenthesized expression (e.g.
88-
// `($this->formatter)()`), wrap the inner text back in
89-
// parens so that `SubjectExpr::parse` sees
90-
// `($this->formatter)()` rather than `$this->formatter()`
91-
// (which would be parsed as a method call).
92-
if matches!(fc.function, Expression::Parenthesized(_)) {
93-
format!("({})({})", func_text, args_text)
94-
} else {
95-
format!("{}({})", func_text, args_text)
96-
}
103+
// `SubjectExpr::to_subject_text` wraps callees that are not
104+
// callable-by-name (property chains, array access, nested calls)
105+
// in parentheses, so `($this->formatter)()` round-trips through
106+
// `SubjectExpr::parse` as an invoke rather than a method call.
107+
Some(SubjectExpr::CallExpr {
108+
callee: Box::new(callee),
109+
args_text,
110+
})
97111
}
98112

99-
Expression::Instantiation(inst) => expr_to_subject_text(inst.class),
113+
// `new Foo(...)` as a subject lowers to just the class name, matching
114+
// the historical behaviour where the constructed instance resolves
115+
// through its class.
116+
Expression::Instantiation(inst) => expr_to_subject_expr(inst.class),
100117

101-
Expression::Parenthesized(paren) => expr_to_subject_text(paren.expression),
118+
Expression::Parenthesized(paren) => expr_to_subject_expr(paren.expression),
102119

103120
// `clone $expr` preserves the type of the operand.
104-
Expression::Clone(clone) => expr_to_subject_text(clone.object),
121+
Expression::Clone(clone) => expr_to_subject_expr(clone.object),
105122

106123
// Array literals: `[Foo::class, 'bar']` → `[Foo::class, 'bar']`.
107124
// We format elements we can represent and elide the rest so that
@@ -111,7 +128,7 @@ pub(super) fn expr_to_subject_text(expr: &Expression<'_>) -> String {
111128
let mut parts = Vec::new();
112129
for element in array.elements.iter() {
113130
match element {
114-
mago_syntax::cst::ArrayElement::KeyValue(kv) => {
131+
ArrayElement::KeyValue(kv) => {
115132
let val = expr_to_subject_text(kv.value);
116133
if !val.is_empty() {
117134
let key = expr_to_subject_text(kv.key);
@@ -124,28 +141,31 @@ pub(super) fn expr_to_subject_text(expr: &Expression<'_>) -> String {
124141
parts.push("...".to_string());
125142
}
126143
}
127-
mago_syntax::cst::ArrayElement::Value(v) => {
144+
ArrayElement::Value(v) => {
128145
let val = expr_to_subject_text(v.value);
129146
if val.is_empty() {
130147
parts.push("...".to_string());
131148
} else {
132149
parts.push(val);
133150
}
134151
}
135-
mago_syntax::cst::ArrayElement::Variadic(v) => {
152+
ArrayElement::Variadic(v) => {
136153
let val = expr_to_subject_text(v.value);
137154
if val.is_empty() {
138155
parts.push("...".to_string());
139156
} else {
140157
parts.push(format!("...{}", val));
141158
}
142159
}
143-
mago_syntax::cst::ArrayElement::Missing(_) => {
160+
ArrayElement::Missing(_) => {
144161
parts.push("...".to_string());
145162
}
146163
}
147164
}
148-
format!("[{}]", parts.join(", "))
165+
Some(SubjectExpr::InlineArray {
166+
elements: parts,
167+
index_segments: Vec::new(),
168+
})
149169
}
150170

151171
// Ternary `$a ? $b : $c` and short ternary `$a ?: $b`.
@@ -155,59 +175,99 @@ pub(super) fn expr_to_subject_text(expr: &Expression<'_>) -> String {
155175
// something to resolve rather than an empty string.
156176
Expression::Conditional(cond) => {
157177
let preferred = cond.then.unwrap_or(cond.condition);
158-
let text = expr_to_subject_text(preferred);
159-
if !text.is_empty() {
160-
return text;
178+
match expr_to_subject_expr(preferred) {
179+
Some(se) if !se.to_subject_text().is_empty() => Some(se),
180+
// Fall back to the else branch.
181+
_ => expr_to_subject_expr(cond.r#else),
161182
}
162-
// Fall back to the else branch.
163-
expr_to_subject_text(cond.r#else)
164183
}
165184

166185
// Null coalesce `$a ?? $b` — LHS is the preferred non-null value.
167186
Expression::Binary(binary) if binary.operator.is_null_coalesce() => {
168-
let text = expr_to_subject_text(binary.lhs);
169-
if !text.is_empty() {
170-
return text;
187+
match expr_to_subject_expr(binary.lhs) {
188+
Some(se) if !se.to_subject_text().is_empty() => Some(se),
189+
_ => expr_to_subject_expr(binary.rhs),
171190
}
172-
expr_to_subject_text(binary.rhs)
173191
}
174192

175193
Expression::ArrayAccess(access) => {
176-
let base = expr_to_subject_text(access.array);
177-
if base.is_empty() {
178-
return String::new();
194+
let base = expr_to_subject_expr(access.array)?;
195+
if base.to_subject_text().is_empty() {
196+
return None;
179197
}
180-
// Preserve string keys for array-shape resolution;
181-
// collapse everything else to `[]` (generic element access),
182-
// matching the convention used by `extract_arrow_subject`.
183-
let bracket = match access.index {
198+
// Preserve string keys for array-shape resolution and integer
199+
// indices for positional narrowing; collapse everything else to
200+
// generic element access (`[]`), matching the convention used by
201+
// `extract_arrow_subject`.
202+
let segment = match access.index {
184203
Expression::Literal(Literal::String(s)) => {
185204
// `s.raw` includes surrounding quotes (e.g. `'key'`).
186-
// Strip them to get the bare key, then re-wrap in
187-
// single quotes for the subject format.
188205
let raw_str = bytes_to_str(s.raw);
189206
let inner = crate::util::unquote_php_string(raw_str).unwrap_or(raw_str);
190-
format!("['{}']", inner)
207+
BracketSegment::StringKey(inner.to_string())
191208
}
192-
// Preserve integer-literal indices so downstream
193-
// narrowing can address a specific element (e.g.
194-
// `$stmts[0]` after `if (! $stmts[0] instanceof Foo)`).
195209
Expression::Literal(Literal::Integer(i)) => {
196210
let n = i
197211
.value
198212
.map(|v| v.to_string())
199213
.unwrap_or_else(|| bytes_to_str(i.raw).to_string());
200-
format!("[{}]", n)
214+
BracketSegment::IntKey(n)
201215
}
202-
_ => "[]".to_string(),
216+
_ => BracketSegment::ElementAccess,
203217
};
204-
format!("{}{}", base, bracket)
218+
Some(SubjectExpr::ArrayAccess {
219+
base: Box::new(base),
220+
segments: vec![segment],
221+
})
205222
}
206223

207-
_ => String::new(),
224+
_ => None,
225+
}
226+
}
227+
228+
/// Lower a property access (`object->property` or `object?->property`) into a
229+
/// [`SubjectExpr`]. A non-identifier selector (dynamic property name) drops
230+
/// to the base expression, matching the original serialiser.
231+
fn lower_property(
232+
object: &Expression<'_>,
233+
property: &ClassLikeMemberSelector<'_>,
234+
) -> Option<SubjectExpr> {
235+
let base = expr_to_subject_expr(object)?;
236+
if let ClassLikeMemberSelector::Identifier(ident) = property {
237+
Some(SubjectExpr::PropertyChain {
238+
base: Box::new(base),
239+
property: bytes_to_str(ident.value).to_string(),
240+
})
241+
} else {
242+
Some(base)
208243
}
209244
}
210245

246+
/// Lower an instance method call (`object->method(args)` or the null-safe
247+
/// form) into a [`SubjectExpr::CallExpr`]. A dynamic method name lowers to a
248+
/// `?` method with no arguments.
249+
fn lower_method_call(
250+
object: &Expression<'_>,
251+
method: &ClassLikeMemberSelector<'_>,
252+
args: &TokenSeparatedSequence<'_, Argument<'_>>,
253+
) -> Option<SubjectExpr> {
254+
let base = expr_to_subject_expr(object)?;
255+
let (method, args_text) = match method {
256+
ClassLikeMemberSelector::Identifier(ident) => (
257+
bytes_to_str(ident.value).to_string(),
258+
format_all_call_args(args),
259+
),
260+
_ => ("?".to_string(), String::new()),
261+
};
262+
Some(SubjectExpr::CallExpr {
263+
callee: Box::new(SubjectExpr::MethodCall {
264+
base: Box::new(base),
265+
method,
266+
}),
267+
args_text,
268+
})
269+
}
270+
211271
/// Format all arguments of a call expression as a comma-separated string.
212272
///
213273
/// Each argument is serialized to a text representation that preserves

0 commit comments

Comments
 (0)