Skip to content

Commit 196cb04

Browse files
committed
Add support for promoted properties
1 parent 9362b21 commit 196cb04

3 files changed

Lines changed: 325 additions & 4 deletions

File tree

src/parser.rs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,30 @@ impl Backend {
388388
let is_static = method.modifiers.iter().any(|m| m.is_static());
389389
let visibility = Self::extract_visibility(method.modifiers.iter());
390390

391+
// Extract promoted properties from constructor parameters.
392+
// A promoted property is a constructor parameter with a
393+
// visibility modifier (e.g. `public`, `private`, `protected`).
394+
if name == "__construct" {
395+
for param in method.parameter_list.parameters.iter() {
396+
if param.is_promoted_property() {
397+
let raw_name = param.variable.name.to_string();
398+
let prop_name =
399+
raw_name.strip_prefix('$').unwrap_or(&raw_name).to_string();
400+
let type_hint =
401+
param.hint.as_ref().map(|h| Self::extract_hint_string(h));
402+
let prop_visibility =
403+
Self::extract_visibility(param.modifiers.iter());
404+
405+
properties.push(PropertyInfo {
406+
name: prop_name,
407+
type_hint,
408+
is_static: false,
409+
visibility: prop_visibility,
410+
});
411+
}
412+
}
413+
}
414+
391415
methods.push(MethodInfo {
392416
name,
393417
parameters,
@@ -401,8 +425,7 @@ impl Backend {
401425
properties.append(&mut prop_infos);
402426
}
403427
ClassLikeMember::Constant(constant) => {
404-
let type_hint =
405-
constant.hint.as_ref().map(|h| Self::extract_hint_string(h));
428+
let type_hint = constant.hint.as_ref().map(|h| Self::extract_hint_string(h));
406429
let visibility = Self::extract_visibility(constant.modifiers.iter());
407430
for item in constant.items.iter() {
408431
constants.push(ConstantInfo {

tests/completion_properties.rs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,3 +285,138 @@ async fn test_completion_constant_detail_with_type_hint() {
285285
_ => panic!("Expected CompletionResponse::Array"),
286286
}
287287
}
288+
289+
#[tokio::test]
290+
async fn test_completion_promoted_properties_appear_in_this() {
291+
let backend = create_test_backend();
292+
293+
let uri = Url::parse("file:///promoted.php").unwrap();
294+
let text = concat!(
295+
"<?php\n",
296+
"class ShoppingCartService {\n",
297+
" private IShoppingCart $regular;\n",
298+
"\n",
299+
" public function __construct(\n",
300+
" private IShoppingCart $promoted,\n",
301+
" ) {}\n",
302+
"\n",
303+
" public function doWork(): void {\n",
304+
" $this->\n",
305+
" }\n",
306+
"}\n",
307+
);
308+
309+
let open_params = DidOpenTextDocumentParams {
310+
text_document: TextDocumentItem {
311+
uri: uri.clone(),
312+
language_id: "php".to_string(),
313+
version: 1,
314+
text: text.to_string(),
315+
},
316+
};
317+
backend.did_open(open_params).await;
318+
319+
// Cursor right after `$this->` on line 9
320+
let completion_params = CompletionParams {
321+
text_document_position: TextDocumentPositionParams {
322+
text_document: TextDocumentIdentifier { uri },
323+
position: Position {
324+
line: 9,
325+
character: 15,
326+
},
327+
},
328+
work_done_progress_params: WorkDoneProgressParams::default(),
329+
partial_result_params: PartialResultParams::default(),
330+
context: None,
331+
};
332+
333+
let result = backend.completion(completion_params).await.unwrap();
334+
assert!(result.is_some(), "Completion should return results");
335+
336+
match result.unwrap() {
337+
CompletionResponse::Array(items) => {
338+
let names: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
339+
assert!(
340+
names.contains(&"regular"),
341+
"Should contain regular property 'regular', got: {:?}",
342+
names
343+
);
344+
assert!(
345+
names.contains(&"promoted"),
346+
"Should contain promoted property 'promoted', got: {:?}",
347+
names
348+
);
349+
}
350+
_ => panic!("Expected CompletionResponse::Array"),
351+
}
352+
}
353+
354+
#[tokio::test]
355+
async fn test_completion_promoted_property_type_resolves_for_chaining() {
356+
let backend = create_test_backend();
357+
358+
let uri = Url::parse("file:///promoted_chain.php").unwrap();
359+
let text = concat!(
360+
"<?php\n",
361+
"class Logger {\n",
362+
" public function info(string $msg): void {}\n",
363+
" public function error(string $msg): void {}\n",
364+
"}\n",
365+
"class Service {\n",
366+
" public function __construct(\n",
367+
" private Logger $logger,\n",
368+
" ) {}\n",
369+
"\n",
370+
" public function run(): void {\n",
371+
" $this->logger->\n",
372+
" }\n",
373+
"}\n",
374+
);
375+
376+
let open_params = DidOpenTextDocumentParams {
377+
text_document: TextDocumentItem {
378+
uri: uri.clone(),
379+
language_id: "php".to_string(),
380+
version: 1,
381+
text: text.to_string(),
382+
},
383+
};
384+
backend.did_open(open_params).await;
385+
386+
// Cursor right after `$this->logger->` on line 11
387+
let completion_params = CompletionParams {
388+
text_document_position: TextDocumentPositionParams {
389+
text_document: TextDocumentIdentifier { uri },
390+
position: Position {
391+
line: 11,
392+
character: 24,
393+
},
394+
},
395+
work_done_progress_params: WorkDoneProgressParams::default(),
396+
partial_result_params: PartialResultParams::default(),
397+
context: None,
398+
};
399+
400+
let result = backend.completion(completion_params).await.unwrap();
401+
assert!(
402+
result.is_some(),
403+
"Completion should resolve promoted property type for chaining"
404+
);
405+
406+
match result.unwrap() {
407+
CompletionResponse::Array(items) => {
408+
let names: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
409+
assert!(
410+
names.iter().any(|n| n.starts_with("info(")),
411+
"Should contain Logger method 'info', got: {:?}",
412+
names
413+
);
414+
assert!(
415+
names.iter().any(|n| n.starts_with("error(")),
416+
"Should contain Logger method 'error', got: {:?}",
417+
names
418+
);
419+
}
420+
_ => panic!("Expected CompletionResponse::Array"),
421+
}
422+
}

tests/parser.rs

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -613,10 +613,173 @@ interface Factory {
613613
assert_eq!(classes[0].name, "Factory");
614614
assert_eq!(classes[0].methods.len(), 2);
615615

616-
let create = classes[0].methods.iter().find(|m| m.name == "create").unwrap();
616+
let create = classes[0]
617+
.methods
618+
.iter()
619+
.find(|m| m.name == "create")
620+
.unwrap();
617621
assert!(create.is_static);
618622
assert_eq!(create.return_type.as_deref(), Some("static"));
619623

620-
let build = classes[0].methods.iter().find(|m| m.name == "build").unwrap();
624+
let build = classes[0]
625+
.methods
626+
.iter()
627+
.find(|m| m.name == "build")
628+
.unwrap();
621629
assert!(!build.is_static);
622630
}
631+
632+
// ─── Promoted Property Tests ────────────────────────────────────────────────
633+
634+
#[tokio::test]
635+
async fn test_parse_php_promoted_properties_basic() {
636+
let backend = create_test_backend();
637+
let php = r#"<?php
638+
class Service {
639+
public function __construct(
640+
private IShoppingCart $cart,
641+
protected Logger $logger,
642+
) {}
643+
}
644+
"#;
645+
646+
let classes = backend.parse_php(php);
647+
assert_eq!(classes.len(), 1);
648+
649+
let cls = &classes[0];
650+
assert_eq!(
651+
cls.properties.len(),
652+
2,
653+
"Should extract 2 promoted properties"
654+
);
655+
656+
let cart = cls.properties.iter().find(|p| p.name == "cart").unwrap();
657+
assert_eq!(cart.type_hint.as_deref(), Some("IShoppingCart"));
658+
assert_eq!(cart.visibility, Visibility::Private);
659+
assert!(!cart.is_static);
660+
661+
let logger = cls.properties.iter().find(|p| p.name == "logger").unwrap();
662+
assert_eq!(logger.type_hint.as_deref(), Some("Logger"));
663+
assert_eq!(logger.visibility, Visibility::Protected);
664+
assert!(!logger.is_static);
665+
}
666+
667+
#[tokio::test]
668+
async fn test_parse_php_promoted_properties_mixed_with_regular() {
669+
let backend = create_test_backend();
670+
let php = r#"<?php
671+
class ShoppingCartService {
672+
private IShoppingCart $regular;
673+
674+
public function __construct(
675+
private IShoppingCart $promoted,
676+
) {}
677+
}
678+
"#;
679+
680+
let classes = backend.parse_php(php);
681+
assert_eq!(classes.len(), 1);
682+
683+
let cls = &classes[0];
684+
assert_eq!(
685+
cls.properties.len(),
686+
2,
687+
"Should have regular + promoted property"
688+
);
689+
690+
let regular = cls.properties.iter().find(|p| p.name == "regular").unwrap();
691+
assert_eq!(regular.type_hint.as_deref(), Some("IShoppingCart"));
692+
assert_eq!(regular.visibility, Visibility::Private);
693+
694+
let promoted = cls
695+
.properties
696+
.iter()
697+
.find(|p| p.name == "promoted")
698+
.unwrap();
699+
assert_eq!(promoted.type_hint.as_deref(), Some("IShoppingCart"));
700+
assert_eq!(promoted.visibility, Visibility::Private);
701+
}
702+
703+
#[tokio::test]
704+
async fn test_parse_php_promoted_property_public_visibility() {
705+
let backend = create_test_backend();
706+
let php = r#"<?php
707+
class Config {
708+
public function __construct(
709+
public string $name,
710+
public int $value,
711+
) {}
712+
}
713+
"#;
714+
715+
let classes = backend.parse_php(php);
716+
assert_eq!(classes.len(), 1);
717+
718+
let cls = &classes[0];
719+
assert_eq!(cls.properties.len(), 2);
720+
721+
for prop in &cls.properties {
722+
assert_eq!(prop.visibility, Visibility::Public);
723+
}
724+
725+
let name = cls.properties.iter().find(|p| p.name == "name").unwrap();
726+
assert_eq!(name.type_hint.as_deref(), Some("string"));
727+
728+
let value = cls.properties.iter().find(|p| p.name == "value").unwrap();
729+
assert_eq!(value.type_hint.as_deref(), Some("int"));
730+
}
731+
732+
#[tokio::test]
733+
async fn test_parse_php_non_promoted_constructor_params_ignored() {
734+
let backend = create_test_backend();
735+
let php = r#"<?php
736+
class Service {
737+
public function __construct(
738+
private string $promoted,
739+
string $regularParam,
740+
) {}
741+
}
742+
"#;
743+
744+
let classes = backend.parse_php(php);
745+
assert_eq!(classes.len(), 1);
746+
747+
let cls = &classes[0];
748+
assert_eq!(
749+
cls.properties.len(),
750+
1,
751+
"Only promoted params (with visibility) should become properties"
752+
);
753+
assert_eq!(cls.properties[0].name, "promoted");
754+
}
755+
756+
#[tokio::test]
757+
async fn test_parse_php_promoted_property_readonly() {
758+
let backend = create_test_backend();
759+
let php = r#"<?php
760+
class User {
761+
public function __construct(
762+
public readonly string $name,
763+
private readonly int $id,
764+
) {}
765+
}
766+
"#;
767+
768+
let classes = backend.parse_php(php);
769+
assert_eq!(classes.len(), 1);
770+
771+
let cls = &classes[0];
772+
assert_eq!(
773+
cls.properties.len(),
774+
2,
775+
"readonly promoted params are still promoted"
776+
);
777+
778+
let name = cls.properties.iter().find(|p| p.name == "name").unwrap();
779+
assert_eq!(name.visibility, Visibility::Public);
780+
assert_eq!(name.type_hint.as_deref(), Some("string"));
781+
782+
let id = cls.properties.iter().find(|p| p.name == "id").unwrap();
783+
assert_eq!(id.visibility, Visibility::Private);
784+
assert_eq!(id.type_hint.as_deref(), Some("int"));
785+
}

0 commit comments

Comments
 (0)