diff --git a/src/completion/builder.rs b/src/completion/builder.rs index 4a28f928..193254c1 100644 --- a/src/completion/builder.rs +++ b/src/completion/builder.rs @@ -94,10 +94,18 @@ impl Backend { /// (same-class access, e.g. `$this->`). /// - `Some(name)` where `name != target_class.name`: **public** and /// **protected** members are shown (the caller might be in a subclass). + /// + /// `is_self_or_ancestor` should be `true` when the cursor is inside the + /// target class itself or inside a class that (transitively) extends the + /// target. When `true`, `__construct` is offered for `::` access + /// (e.g. `self::__construct()`, `parent::__construct()`, + /// `ClassName::__construct()` from within a subclass). When `false`, + /// magic methods are suppressed entirely. pub(crate) fn build_completion_items( target_class: &ClassInfo, access_kind: AccessKind, current_class_name: Option<&str>, + is_self_or_ancestor: bool, ) -> Vec { // Determine whether we are inside the same class as the target. let same_class = current_class_name.is_some_and(|name| name == target_class.name); @@ -108,12 +116,13 @@ impl Backend { // Methods — filtered by static / instance, excluding magic methods for method in &target_class.methods { // `__construct` is meaningful to call explicitly via `::` or - // `parent::` (e.g. `parent::__construct(...)` in a child class), - // so we only suppress it for `->` access. All other magic - // methods are always suppressed. + // `parent::` when inside the same class or a subclass + // (e.g. `parent::__construct(...)`, `self::__construct()`). + // Outside of that relationship, magic methods are suppressed. let is_constructor = method.name.eq_ignore_ascii_case("__construct"); if Self::is_magic_method(&method.name) { let allow = is_constructor + && is_self_or_ancestor && matches!( access_kind, AccessKind::DoubleColon | AccessKind::ParentDoubleColon @@ -235,6 +244,19 @@ impl Backend { } } + // `::class` keyword — returns the fully qualified class name as a string. + // Available on any class, interface, or enum via `::` access. + if access_kind == AccessKind::DoubleColon || access_kind == AccessKind::ParentDoubleColon { + items.push(CompletionItem { + label: "class".to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some("class-string".to_string()), + insert_text: Some("class".to_string()), + filter_text: Some("class".to_string()), + ..CompletionItem::default() + }); + } + // Sort all items alphabetically (case-insensitive) and assign // sort_text so the editor preserves this ordering. items.sort_by(|a, b| { diff --git a/src/server.rs b/src/server.rs index 9d7b41aa..8ad73a01 100644 --- a/src/server.rs +++ b/src/server.rs @@ -393,10 +393,43 @@ impl LanguageServer for Backend { for target_class in &candidates { let merged = Self::resolve_class_with_inheritance(target_class, &class_loader); + + // Determine whether the cursor is inside the target + // class itself or inside a (transitive) subclass. + // This controls whether `__construct` is offered + // via `::` access. + let is_self_or_ancestor = if let Some(cc) = current_class { + if cc.name == target_class.name { + true + } else { + // Walk the parent chain of the current class + // to see if the target is an ancestor. + let mut ancestor_name = cc.parent_class.clone(); + let mut found = false; + let mut depth = 0u32; + while let Some(ref name) = ancestor_name { + depth += 1; + if depth > 20 { + break; + } + if *name == target_class.name { + found = true; + break; + } + ancestor_name = + class_loader(name).and_then(|ci| ci.parent_class.clone()); + } + found + } + } else { + false + }; + let items = Self::build_completion_items( &merged, effective_access, current_class_name, + is_self_or_ancestor, ); for item in items { if !all_items diff --git a/tests/completion_access_kind.rs b/tests/completion_access_kind.rs index c9abb1a0..fd17c08c 100644 --- a/tests/completion_access_kind.rs +++ b/tests/completion_access_kind.rs @@ -361,3 +361,542 @@ async fn test_completion_arrow_with_partial_typed_identifier() { _ => panic!("Expected CompletionResponse::Array"), } } + +// ─── __construct visibility via :: access ─────────────────────────────────── + +#[tokio::test] +async fn test_construct_shown_via_self_inside_same_class() { + let backend = create_test_backend(); + + let uri = Url::parse("file:///construct_self.php").unwrap(); + let text = concat!( + " { + let method_names: Vec<&str> = items + .iter() + .filter(|i| i.kind == Some(CompletionItemKind::METHOD)) + .map(|i| i.filter_text.as_deref().unwrap()) + .collect(); + assert!( + method_names.contains(&"__construct"), + "self:: inside same class should show __construct, got: {:?}", + method_names + ); + } + _ => panic!("Expected CompletionResponse::Array"), + } +} + +#[tokio::test] +async fn test_construct_shown_via_classname_inside_same_class() { + let backend = create_test_backend(); + + let uri = Url::parse("file:///construct_classname.php").unwrap(); + let text = concat!( + " { + let method_names: Vec<&str> = items + .iter() + .filter(|i| i.kind == Some(CompletionItemKind::METHOD)) + .map(|i| i.filter_text.as_deref().unwrap()) + .collect(); + assert!( + method_names.contains(&"__construct"), + "A:: inside class A should show __construct, got: {:?}", + method_names + ); + } + _ => panic!("Expected CompletionResponse::Array"), + } +} + +#[tokio::test] +async fn test_construct_shown_via_static_inside_same_class() { + let backend = create_test_backend(); + + let uri = Url::parse("file:///construct_static.php").unwrap(); + let text = concat!( + " { + let method_names: Vec<&str> = items + .iter() + .filter(|i| i.kind == Some(CompletionItemKind::METHOD)) + .map(|i| i.filter_text.as_deref().unwrap()) + .collect(); + assert!( + method_names.contains(&"__construct"), + "static:: inside same class should show __construct, got: {:?}", + method_names + ); + } + _ => panic!("Expected CompletionResponse::Array"), + } +} + +#[tokio::test] +async fn test_construct_shown_via_parent_in_subclass() { + let backend = create_test_backend(); + + let uri = Url::parse("file:///construct_parent.php").unwrap(); + let text = concat!( + " { + let method_names: Vec<&str> = items + .iter() + .filter(|i| i.kind == Some(CompletionItemKind::METHOD)) + .map(|i| i.filter_text.as_deref().unwrap()) + .collect(); + assert!( + method_names.contains(&"__construct"), + "parent:: in subclass should show __construct, got: {:?}", + method_names + ); + } + _ => panic!("Expected CompletionResponse::Array"), + } +} + +#[tokio::test] +async fn test_construct_shown_via_self_in_subclass() { + let backend = create_test_backend(); + + let uri = Url::parse("file:///construct_self_sub.php").unwrap(); + let text = concat!( + " { + let method_names: Vec<&str> = items + .iter() + .filter(|i| i.kind == Some(CompletionItemKind::METHOD)) + .map(|i| i.filter_text.as_deref().unwrap()) + .collect(); + assert!( + method_names.contains(&"__construct"), + "self:: in subclass should show __construct, got: {:?}", + method_names + ); + } + _ => panic!("Expected CompletionResponse::Array"), + } +} + +#[tokio::test] +async fn test_construct_shown_via_parent_classname_in_subclass() { + let backend = create_test_backend(); + + let uri = Url::parse("file:///construct_parent_name.php").unwrap(); + let text = concat!( + " { + let method_names: Vec<&str> = items + .iter() + .filter(|i| i.kind == Some(CompletionItemKind::METHOD)) + .map(|i| i.filter_text.as_deref().unwrap()) + .collect(); + assert!( + method_names.contains(&"__construct"), + "A:: inside subclass B should show __construct, got: {:?}", + method_names + ); + } + _ => panic!("Expected CompletionResponse::Array"), + } +} + +#[tokio::test] +async fn test_construct_hidden_via_classname_outside_class() { + let backend = create_test_backend(); + + let uri = Url::parse("file:///construct_outside.php").unwrap(); + let text = concat!( + " = items + .iter() + .filter(|i| i.kind == Some(CompletionItemKind::METHOD)) + .map(|i| i.filter_text.as_deref().unwrap()) + .collect(); + assert!( + !method_names.contains(&"__construct"), + "A:: outside any class should NOT show __construct, got: {:?}", + method_names + ); + } + // No results at all is also acceptable — no magic methods to show +} + +#[tokio::test] +async fn test_construct_hidden_via_classname_in_unrelated_class() { + let backend = create_test_backend(); + + let uri = Url::parse("file:///construct_unrelated.php").unwrap(); + let text = concat!( + " { + let method_names: Vec<&str> = items + .iter() + .filter(|i| i.kind == Some(CompletionItemKind::METHOD)) + .map(|i| i.filter_text.as_deref().unwrap()) + .collect(); + assert!( + method_names.contains(&"make"), + "A:: in unrelated class C should still show static method 'make', got: {:?}", + method_names + ); + assert!( + !method_names.contains(&"__construct"), + "A:: in unrelated class C should NOT show __construct, got: {:?}", + method_names + ); + } + _ => panic!("Expected CompletionResponse::Array"), + } +} + +// ─── ::class keyword completion ───────────────────────────────────────────── + +#[tokio::test] +async fn test_double_colon_shows_class_keyword() { + let backend = create_test_backend(); + + let uri = Url::parse("file:///class_keyword.php").unwrap(); + let text = concat!( + " { + let keyword_labels: Vec<&str> = items + .iter() + .filter(|i| i.kind == Some(CompletionItemKind::KEYWORD)) + .map(|i| i.label.as_str()) + .collect(); + assert!( + keyword_labels.contains(&"class"), + "self:: should offer ::class keyword, got: {:?}", + keyword_labels + ); + } + _ => panic!("Expected CompletionResponse::Array"), + } +}