@@ -116,6 +116,44 @@ pub(crate) fn split_union_depth0(s: &str) -> Vec<&str> {
116116 parts
117117}
118118
119+ /// Split a type string on `&` (intersection) at depth 0, respecting
120+ /// `<…>`, `(…)`, and `{…}` nesting.
121+ ///
122+ /// This is necessary so that intersection operators inside generic
123+ /// parameters or object/array shapes (e.g. `object{foo: A&B}`) are not
124+ /// mistaken for top-level intersection splits.
125+ ///
126+ /// # Examples
127+ ///
128+ /// - `"User&JsonSerializable"` → `["User", "JsonSerializable"]`
129+ /// - `"object{foo: int}&\stdClass"` → `["object{foo: int}", "\stdClass"]`
130+ /// - `"object{foo: A&B}"` → `["object{foo: A&B}"]` (no split — `&` is nested)
131+ pub fn split_intersection_depth0 ( s : & str ) -> Vec < & str > {
132+ let mut parts = Vec :: new ( ) ;
133+ let mut depth_angle = 0i32 ;
134+ let mut depth_paren = 0i32 ;
135+ let mut depth_brace = 0i32 ;
136+ let mut start = 0 ;
137+
138+ for ( i, c) in s. char_indices ( ) {
139+ match c {
140+ '<' => depth_angle += 1 ,
141+ '>' => depth_angle -= 1 ,
142+ '(' => depth_paren += 1 ,
143+ ')' => depth_paren -= 1 ,
144+ '{' => depth_brace += 1 ,
145+ '}' => depth_brace -= 1 ,
146+ '&' if depth_angle == 0 && depth_paren == 0 && depth_brace == 0 => {
147+ parts. push ( & s[ start..i] ) ;
148+ start = i + c. len_utf8 ( ) ;
149+ }
150+ _ => { }
151+ }
152+ }
153+ parts. push ( & s[ start..] ) ;
154+ parts
155+ }
156+
119157/// Clean a raw type string from a docblock, **preserving** generic
120158/// parameters so that downstream resolution can apply generic
121159/// substitution.
@@ -718,3 +756,108 @@ pub fn extract_array_shape_value_type(type_str: &str, key: &str) -> Option<Strin
718756 . find ( |e| e. key == key)
719757 . map ( |e| e. value_type )
720758}
759+
760+ // ─── Object Shape Parsing ───────────────────────────────────────────────────
761+
762+ /// Parse a PHPStan object shape type string into its constituent entries.
763+ ///
764+ /// Object shapes describe an anonymous object with typed properties:
765+ ///
766+ /// # Examples
767+ ///
768+ /// - `"object{foo: int, bar: string}"` → two entries
769+ /// - `"object{foo: int, bar?: string}"` → "bar" is optional
770+ /// - `"object{'foo': int, \"bar\": string}"` → quoted property names
771+ /// - `"object{foo: int, bar: string}&\stdClass"` → intersection ignored here
772+ ///
773+ /// The returned entries reuse [`ArrayShapeEntry`] since the structure is
774+ /// identical (key name, value type, optional flag).
775+ ///
776+ /// Returns `None` if the type is not an object shape.
777+ pub fn parse_object_shape ( type_str : & str ) -> Option < Vec < ArrayShapeEntry > > {
778+ let s = type_str. strip_prefix ( '\\' ) . unwrap_or ( type_str) ;
779+ let s = s. strip_prefix ( '?' ) . unwrap_or ( s) ;
780+
781+ // Must start with `object{` (case-insensitive base).
782+ let brace_pos = s. find ( '{' ) ?;
783+ let base = & s[ ..brace_pos] ;
784+ if !base. eq_ignore_ascii_case ( "object" ) {
785+ return None ;
786+ }
787+
788+ // Extract the content between `{` and the matching `}`.
789+ let rest = & s[ brace_pos + 1 ..] ;
790+ let close_pos = find_matching_brace_close ( rest) ;
791+ let inner = rest[ ..close_pos] . trim ( ) ;
792+
793+ if inner. is_empty ( ) {
794+ return Some ( vec ! [ ] ) ;
795+ }
796+
797+ // Reuse the same splitting and key-value parsing as array shapes —
798+ // the syntax is identical (`key: Type`, `key?: Type`, quoted keys).
799+ let raw_entries = split_shape_entries ( inner) ;
800+ let mut entries = Vec :: with_capacity ( raw_entries. len ( ) ) ;
801+
802+ for raw in raw_entries {
803+ let raw = raw. trim ( ) ;
804+ if raw. is_empty ( ) {
805+ continue ;
806+ }
807+
808+ if let Some ( ( key_part, value_part) ) = split_shape_key_value ( raw) {
809+ let key_trimmed = key_part. trim ( ) ;
810+ let value_trimmed = value_part. trim ( ) ;
811+
812+ let ( key, optional) = if let Some ( k) = key_trimmed. strip_suffix ( '?' ) {
813+ ( k. to_string ( ) , true )
814+ } else {
815+ ( key_trimmed. to_string ( ) , false )
816+ } ;
817+
818+ let key = strip_shape_key_quotes ( & key) ;
819+
820+ entries. push ( ArrayShapeEntry {
821+ key,
822+ value_type : value_trimmed. to_string ( ) ,
823+ optional,
824+ } ) ;
825+ }
826+ // Object shapes don't have positional entries — skip anything
827+ // without an explicit key.
828+ }
829+
830+ Some ( entries)
831+ }
832+
833+ /// Check whether a type string is an object shape (`object{…}`).
834+ ///
835+ /// Returns `true` for `"object{foo: int}"`, `"?object{bar: string}"`,
836+ /// and `"\object{baz: bool}"`. Returns `false` for bare `"object"`.
837+ pub fn is_object_shape ( type_str : & str ) -> bool {
838+ let s = type_str. strip_prefix ( '\\' ) . unwrap_or ( type_str) ;
839+ let s = s. strip_prefix ( '?' ) . unwrap_or ( s) ;
840+ // Check for `object{` case-insensitively, but only when `{` immediately
841+ // follows the word `object` (no intervening whitespace).
842+ if let Some ( brace_pos) = s. find ( '{' ) {
843+ let base = & s[ ..brace_pos] ;
844+ base. eq_ignore_ascii_case ( "object" )
845+ } else {
846+ false
847+ }
848+ }
849+
850+ /// Look up the value type for a specific property in an object shape.
851+ ///
852+ /// Given a type like `"object{name: string, user: User}"` and key `"user"`,
853+ /// returns `Some("User")`.
854+ ///
855+ /// Returns `None` if the type is not an object shape or the property
856+ /// is not found.
857+ pub fn extract_object_shape_property_type ( type_str : & str , prop : & str ) -> Option < String > {
858+ let entries = parse_object_shape ( type_str) ?;
859+ entries
860+ . into_iter ( )
861+ . find ( |e| e. key == prop)
862+ . map ( |e| e. value_type )
863+ }
0 commit comments