Skip to content

Commit 60ccff3

Browse files
committed
Add support for @Property
1 parent 453d574 commit 60ccff3

4 files changed

Lines changed: 637 additions & 3 deletions

File tree

src/definition/resolve.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,6 +1003,34 @@ impl Backend {
10031003
}
10041004
}
10051005

1006+
// Fallback: for properties, check if this is a magic property
1007+
// declared via a `@property` tag in the class docblock.
1008+
// Lines look like: ` * @property Type $propertyName`
1009+
if kind == MemberKind::Property {
1010+
let var_pattern = format!("${}", member_name);
1011+
for (line_idx, line) in content.lines().enumerate() {
1012+
if let Some(col) = line.find(&var_pattern) {
1013+
let after_pos = col + var_pattern.len();
1014+
let after_ok =
1015+
after_pos >= line.len() || is_word_boundary(line.as_bytes()[after_pos]);
1016+
if !after_ok {
1017+
continue;
1018+
}
1019+
1020+
let trimmed = line.trim().trim_start_matches('*').trim();
1021+
if trimmed.starts_with("@property-read")
1022+
|| trimmed.starts_with("@property-write")
1023+
|| trimmed.starts_with("@property")
1024+
{
1025+
return Some(Position {
1026+
line: line_idx as u32,
1027+
character: col as u32,
1028+
});
1029+
}
1030+
}
1031+
}
1032+
}
1033+
10061034
None
10071035
}
10081036

src/docblock.rs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {
458544
mod 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]

src/parser.rs

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -517,9 +517,32 @@ impl Backend {
517517
.as_ref()
518518
.and_then(|ext| ext.types.first().map(|ident| ident.value().to_string()));
519519

520-
let (methods, properties, constants, used_traits) =
520+
let (methods, mut properties, constants, used_traits) =
521521
Self::extract_class_like_members(class.members.iter(), doc_ctx);
522522

523+
// Extract @property tags from the class-level docblock.
524+
// These declare magic properties accessible via __get/__set.
525+
if let Some(ctx) = doc_ctx
526+
&& let Some(doc_text) =
527+
docblock::get_docblock_text_for_node(ctx.trivias, ctx.content, class)
528+
{
529+
for (name, type_str) in docblock::extract_property_tags(doc_text) {
530+
// Only add if not already declared as a real property.
531+
if !properties.iter().any(|p| p.name == name) {
532+
properties.push(PropertyInfo {
533+
name,
534+
type_hint: if type_str.is_empty() {
535+
None
536+
} else {
537+
Some(type_str)
538+
},
539+
is_static: false,
540+
visibility: Visibility::Public,
541+
});
542+
}
543+
}
544+
}
545+
523546
let start_offset = class.left_brace.start.offset;
524547
let end_offset = class.right_brace.end.offset;
525548

@@ -544,9 +567,30 @@ impl Backend {
544567
.as_ref()
545568
.and_then(|ext| ext.types.first().map(|ident| ident.value().to_string()));
546569

547-
let (methods, properties, constants, used_traits) =
570+
let (methods, mut properties, constants, used_traits) =
548571
Self::extract_class_like_members(iface.members.iter(), doc_ctx);
549572

573+
// Extract @property tags from the interface-level docblock.
574+
if let Some(ctx) = doc_ctx
575+
&& let Some(doc_text) =
576+
docblock::get_docblock_text_for_node(ctx.trivias, ctx.content, iface)
577+
{
578+
for (name, type_str) in docblock::extract_property_tags(doc_text) {
579+
if !properties.iter().any(|p| p.name == name) {
580+
properties.push(PropertyInfo {
581+
name,
582+
type_hint: if type_str.is_empty() {
583+
None
584+
} else {
585+
Some(type_str)
586+
},
587+
is_static: false,
588+
visibility: Visibility::Public,
589+
});
590+
}
591+
}
592+
}
593+
550594
let start_offset = iface.left_brace.start.offset;
551595
let end_offset = iface.right_brace.end.offset;
552596

@@ -564,9 +608,32 @@ impl Backend {
564608
Statement::Trait(trait_def) => {
565609
let trait_name = trait_def.name.value.to_string();
566610

567-
let (methods, properties, constants, used_traits) =
611+
let (methods, mut properties, constants, used_traits) =
568612
Self::extract_class_like_members(trait_def.members.iter(), doc_ctx);
569613

614+
// Extract @property tags from the trait-level docblock.
615+
if let Some(ctx) = doc_ctx
616+
&& let Some(doc_text) = docblock::get_docblock_text_for_node(
617+
ctx.trivias,
618+
ctx.content,
619+
trait_def,
620+
) {
621+
for (name, type_str) in docblock::extract_property_tags(doc_text) {
622+
if !properties.iter().any(|p| p.name == name) {
623+
properties.push(PropertyInfo {
624+
name,
625+
type_hint: if type_str.is_empty() {
626+
None
627+
} else {
628+
Some(type_str)
629+
},
630+
is_static: false,
631+
visibility: Visibility::Public,
632+
});
633+
}
634+
}
635+
}
636+
570637
let start_offset = trait_def.left_brace.start.offset;
571638
let end_offset = trait_def.right_brace.end.offset;
572639

0 commit comments

Comments
 (0)