@@ -2,106 +2,123 @@ use mago_syntax::cst::sequence::TokenSeparatedSequence;
22use mago_syntax:: cst:: * ;
33
44use 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.
1019pub ( 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