@@ -51,6 +51,92 @@ pub fn extract_var_type(docblock: &str) -> Option<String> {
5151 extract_tag_type ( docblock, "@var" )
5252}
5353
54+ /// Extract all `@property` tags from a class-level docblock.
55+ ///
56+ /// PHPDoc `@property` tags declare magic properties that are accessible via
57+ /// `__get` / `__set`. The format is:
58+ ///
59+ /// - `@property Type $name`
60+ /// - `@property null|Type $name`
61+ /// - `@property ?Type $name`
62+ /// - `@property-read Type $name`
63+ /// - `@property-write Type $name`
64+ ///
65+ /// Returns a list of `(property_name, cleaned_type)` pairs. The property
66+ /// name is returned **without** the `$` prefix.
67+ pub fn extract_property_tags ( docblock : & str ) -> Vec < ( String , String ) > {
68+ let inner = docblock
69+ . trim ( )
70+ . strip_prefix ( "/**" )
71+ . unwrap_or ( docblock)
72+ . strip_suffix ( "*/" )
73+ . unwrap_or ( docblock) ;
74+
75+ let mut results = Vec :: new ( ) ;
76+
77+ for line in inner. lines ( ) {
78+ let trimmed = line. trim ( ) . trim_start_matches ( '*' ) . trim ( ) ;
79+
80+ // Match @property, @property-read, and @property-write
81+ let rest = if let Some ( r) = trimmed. strip_prefix ( "@property-read" ) {
82+ r
83+ } else if let Some ( r) = trimmed. strip_prefix ( "@property-write" ) {
84+ r
85+ } else if let Some ( r) = trimmed. strip_prefix ( "@property" ) {
86+ r
87+ } else {
88+ continue ;
89+ } ;
90+
91+ // The tag must be followed by whitespace.
92+ let rest = rest. trim_start ( ) ;
93+ if rest. is_empty ( ) {
94+ continue ;
95+ }
96+
97+ // The type may be a compound like `null|int`, `?Foo`, or a generic
98+ // like `Collection<int, Model>` that spans multiple whitespace-
99+ // delimited tokens. We take the first token as the type (for
100+ // `clean_type` purposes) and then scan forward until we find a
101+ // token starting with `$`.
102+ //
103+ // Format: @property Type $name (or) @property $name
104+ let mut parts = rest. split_whitespace ( ) ;
105+ let first = match parts. next ( ) {
106+ Some ( t) => t,
107+ None => continue ,
108+ } ;
109+
110+ let ( type_str, prop_name) = if first. starts_with ( '$' ) {
111+ // No explicit type: `@property $name`
112+ ( None , first)
113+ } else {
114+ // Type is the first token; scan forward to find the `$name`.
115+ let mut found_name = None ;
116+ for token in parts {
117+ if token. starts_with ( '$' ) {
118+ found_name = Some ( token) ;
119+ break ;
120+ }
121+ }
122+ match found_name {
123+ Some ( name) => ( Some ( first) , name) ,
124+ None => continue ,
125+ }
126+ } ;
127+
128+ let name = prop_name. strip_prefix ( '$' ) . unwrap_or ( prop_name) ;
129+ if name. is_empty ( ) {
130+ continue ;
131+ }
132+
133+ let cleaned = type_str. map ( clean_type) ;
134+ results. push ( ( name. to_string ( ) , cleaned. unwrap_or_default ( ) ) ) ;
135+ }
136+
137+ results
138+ }
139+
54140/// Decide whether a docblock type should override a native type hint.
55141///
56142/// Returns `true` when:
@@ -458,6 +544,100 @@ fn is_scalar(type_name: &str) -> bool {
458544mod tests {
459545 use super :: * ;
460546
547+ // ─── @property tag extraction ───────────────────────────────────────
548+
549+ #[ test]
550+ fn property_tag_simple ( ) {
551+ let doc = "/** @property Session $session */" ;
552+ let props = extract_property_tags ( doc) ;
553+ assert_eq ! ( props, vec![ ( "session" . to_string( ) , "Session" . to_string( ) ) ] ) ;
554+ }
555+
556+ #[ test]
557+ fn property_tag_nullable ( ) {
558+ let doc = "/** @property ?int $count */" ;
559+ let props = extract_property_tags ( doc) ;
560+ assert_eq ! ( props, vec![ ( "count" . to_string( ) , "?int" . to_string( ) ) ] ) ;
561+ }
562+
563+ #[ test]
564+ fn property_tag_union_with_null ( ) {
565+ let doc = "/** @property null|int $latest_id */" ;
566+ let props = extract_property_tags ( doc) ;
567+ assert_eq ! ( props, vec![ ( "latest_id" . to_string( ) , "int" . to_string( ) ) ] ) ;
568+ }
569+
570+ #[ test]
571+ fn property_tag_fqn ( ) {
572+ let doc = "/** @property \\ App\\ Models\\ User $user */" ;
573+ let props = extract_property_tags ( doc) ;
574+ assert_eq ! (
575+ props,
576+ vec![ ( "user" . to_string( ) , "App\\ Models\\ User" . to_string( ) ) ]
577+ ) ;
578+ }
579+
580+ #[ test]
581+ fn property_tag_multiple ( ) {
582+ let doc = concat ! (
583+ "/**\n " ,
584+ " * @property null|int $latest_subscription_agreement_id\n " ,
585+ " * @property UserMobileVerificationState $mobile_verification_state\n " ,
586+ " */" ,
587+ ) ;
588+ let props = extract_property_tags ( doc) ;
589+ assert_eq ! ( props. len( ) , 2 ) ;
590+ assert_eq ! (
591+ props[ 0 ] ,
592+ (
593+ "latest_subscription_agreement_id" . to_string( ) ,
594+ "int" . to_string( )
595+ )
596+ ) ;
597+ assert_eq ! (
598+ props[ 1 ] ,
599+ (
600+ "mobile_verification_state" . to_string( ) ,
601+ "UserMobileVerificationState" . to_string( )
602+ )
603+ ) ;
604+ }
605+
606+ #[ test]
607+ fn property_tag_read_write_variants ( ) {
608+ let doc = concat ! (
609+ "/**\n " ,
610+ " * @property-read string $name\n " ,
611+ " * @property-write int $age\n " ,
612+ " */" ,
613+ ) ;
614+ let props = extract_property_tags ( doc) ;
615+ assert_eq ! ( props. len( ) , 2 ) ;
616+ assert_eq ! ( props[ 0 ] , ( "name" . to_string( ) , "string" . to_string( ) ) ) ;
617+ assert_eq ! ( props[ 1 ] , ( "age" . to_string( ) , "int" . to_string( ) ) ) ;
618+ }
619+
620+ #[ test]
621+ fn property_tag_no_type ( ) {
622+ let doc = "/** @property $thing */" ;
623+ let props = extract_property_tags ( doc) ;
624+ assert_eq ! ( props, vec![ ( "thing" . to_string( ) , "" . to_string( ) ) ] ) ;
625+ }
626+
627+ #[ test]
628+ fn property_tag_generic_stripped ( ) {
629+ let doc = "/** @property Collection<int, Model> $items */" ;
630+ let props = extract_property_tags ( doc) ;
631+ assert_eq ! ( props, vec![ ( "items" . to_string( ) , "Collection" . to_string( ) ) ] ) ;
632+ }
633+
634+ #[ test]
635+ fn property_tag_none_when_missing ( ) {
636+ let doc = "/** @return Foo */" ;
637+ let props = extract_property_tags ( doc) ;
638+ assert ! ( props. is_empty( ) ) ;
639+ }
640+
461641 // ── extract_return_type (skips conditionals) ────────────────────────
462642
463643 #[ test]
0 commit comments