diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b44f1cd5..e67d0e25 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Static methods complete on instance access.** Member completion after `->` now offers a class's static methods alongside its instance methods, since PHP lets you call a static method through an instance (`$obj->make()`). Static properties remain excluded, as they are only reachable via `::`. Contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/174. - **Array-callable navigation.** Method-name strings in array callables — `[Controller::class, 'method']` and `[$object, 'method']` — now resolve like a real member reference. This makes go-to-definition, find-references, and rename work on Laravel controller actions such as `Route::get('/', [IndexPageController::class, 'indexPage'])`. - **Magic methods complete when implemented.** Magic methods declared on a class (`__invoke`, `__toString`, `__call`, and the rest) are now offered in member completion, so explicit calls like `$x->__invoke()` autocomplete and support go-to-definition. They are sorted below the regular methods so they never appear at the top of the list. - **Staleness detection and auto-refresh.** The class index, function index, and constant index now stay fresh automatically. When PHP files are created or deleted outside the editor (e.g. `git checkout`, code generation), the indices update without a restart, and edits made outside the editor are reflected the next time the file is used. When `composer.json` or `composer.lock` changes (e.g. after `composer install`), vendor packages are rescanned automatically. diff --git a/src/completion/builder.rs b/src/completion/builder.rs index 09d89380..f3a02448 100644 --- a/src/completion/builder.rs +++ b/src/completion/builder.rs @@ -283,7 +283,8 @@ pub(crate) fn build_method_label(method: &MethodInfo) -> String { /// Build completion items for a resolved class, filtered by access kind /// and visibility scope. /// -/// - `Arrow` access: returns only non-static methods and properties. +/// - `Arrow` access: returns both instance and static methods, but only +/// non-static properties. /// - `DoubleColon` access: returns only static methods, static properties, and constants. /// - `ParentDoubleColon` access: returns both static and non-static methods, /// static properties, and constants — but excludes private members. @@ -352,7 +353,9 @@ pub(crate) fn build_completion_items( } let include = match access_kind { - AccessKind::Arrow => !method.is_static, + // PHP allows calling static methods through an instance, so + // surface them in `->` completion as well. + AccessKind::Arrow => true, // External `ClassName::` shows only static methods, but // `__construct` is an exception — it's an instance method // that is routinely called via `ClassName::__construct()` diff --git a/tests/fixtures/completion/instance_no_static_only.fixture b/tests/fixtures/completion/instance_no_static_only.fixture index 6e56e747..b85e8735 100644 --- a/tests/fixtures/completion/instance_no_static_only.fixture +++ b/tests/fixtures/completion/instance_no_static_only.fixture @@ -1,9 +1,9 @@ -// test: static method not shown on instance -> access +// test: static method shown on instance -> access // feature: completion // Adapted from phpactor WorseClassMemberCompletorTest 'shows static member on instance method' -// PHPantom does not show static methods on instance -> access, which is correct +// PHP allows calling static methods through an instance, so completion should show them. // expect: hello( -// expect_absent: goodbye( +// expect: goodbye( --- <> \ No newline at end of file +$bar-><> diff --git a/tests/fixtures/member_access/static_on_instance.fixture b/tests/fixtures/member_access/static_on_instance.fixture index ec01fc89..6df33275 100644 --- a/tests/fixtures/member_access/static_on_instance.fixture +++ b/tests/fixtures/member_access/static_on_instance.fixture @@ -1,9 +1,9 @@ -// test: static method hidden from instance -> access +// test: static method shown on instance -> access // feature: completion // Adapted from phpactor WorseClassMemberCompletorTest 'shows static member on instance method' -// PHPantom deliberately hides static-only members from -> access +// PHP allows calling static methods through an instance, so completion should show them. // expect: hello( -// expect_absent: goodbye( +// expect: goodbye( --- <> \ No newline at end of file +$bar-><> diff --git a/tests/integration/completion_access_kind.rs b/tests/integration/completion_access_kind.rs index d738a639..2eb91ab7 100644 --- a/tests/integration/completion_access_kind.rs +++ b/tests/integration/completion_access_kind.rs @@ -163,10 +163,11 @@ async fn test_completion_arrow_shows_only_non_static() { method_names.contains(&"helper"), "Arrow should include non-static method 'helper'" ); - // Should NOT include static method `create` + // PHP allows calling static methods through an instance, + // so `->` access should include static methods too. assert!( - !method_names.contains(&"create"), - "Arrow should exclude static method 'create'" + method_names.contains(&"create"), + "Arrow should include static method 'create' (PHP allows static calls via instance)" ); // Should include non-static property `count` @@ -350,10 +351,11 @@ async fn test_completion_arrow_with_partial_typed_identifier() { "Should include instanceMethod" ); assert!(method_names.contains(&"test"), "Should include test"); - // Should NOT include static method even with partial typing + // PHP allows calling static methods through an instance, + // so `->` access should include static methods too. assert!( - !method_names.contains(&"staticMethod"), - "Should exclude staticMethod when using ->" + method_names.contains(&"staticMethod"), + "Should include staticMethod when using -> (PHP allows static calls via instance)" ); } _ => panic!("Expected CompletionResponse::Array"), diff --git a/tests/integration/completion_cross_file.rs b/tests/integration/completion_cross_file.rs index 277da505..c14a67a1 100644 --- a/tests/integration/completion_cross_file.rs +++ b/tests/integration/completion_cross_file.rs @@ -959,10 +959,10 @@ async fn test_cross_file_classmap_resolution() { "Should include instance method 'render' resolved via classmap, got {:?}", method_names ); - // Static method should not appear via -> + // PHP allows calling static methods through an instance. assert!( - !method_names.contains(&"create"), - "Should exclude static 'create' from -> access" + method_names.contains(&"create"), + "Should include static 'create' on -> access (PHP allows static calls via instance)" ); } _ => panic!("Expected CompletionResponse::Array"), diff --git a/tests/integration/completion_properties.rs b/tests/integration/completion_properties.rs index 5733b1bc..4feb0bd6 100644 --- a/tests/integration/completion_properties.rs +++ b/tests/integration/completion_properties.rs @@ -500,6 +500,72 @@ async fn test_completion_private_method_hidden_outside_class() { } } +#[tokio::test] +async fn test_completion_instance_access_includes_public_static_methods() { + let backend = create_test_backend(); + + let uri = Url::parse("file:///vis_instance_static_method.php").unwrap(); + let text = concat!( + "\n", + ); + + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: text.to_string(), + }, + }) + .await; + + let result = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: Position { + line: 6, + character: 6, + }, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .unwrap(); + + assert!(result.is_some(), "Should return completions"); + match result.unwrap() { + CompletionResponse::Array(items) => { + 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(&"run"), + "Should include instance method 'run', got: {:?}", + method_names + ); + assert!( + method_names.contains(&"make"), + "Should include static method 'make' on instance access, got: {:?}", + method_names + ); + } + _ => panic!("Expected CompletionResponse::Array"), + } +} + /// `$this->` inside the same class should show private and protected members. #[tokio::test] async fn test_completion_private_and_protected_visible_inside_own_class() { diff --git a/tests/integration/completion_property_chains.rs b/tests/integration/completion_property_chains.rs index 4fd9bf62..9933c6e7 100644 --- a/tests/integration/completion_property_chains.rs +++ b/tests/integration/completion_property_chains.rs @@ -1154,10 +1154,10 @@ async fn test_var_property_chain_no_static_members() { "Should include instance method 'get' from Cache. Got: {:?}", labels ); - // Static method `flush` should NOT appear for `->` access + // PHP allows calling static methods through an instance. assert!( - !labels.iter().any(|l| l.starts_with("flush")), - "Should NOT include static method 'flush' via ->. Got: {:?}", + labels.iter().any(|l| l.starts_with("flush")), + "Should include static method 'flush' via -> (PHP allows static calls via instance). Got: {:?}", labels ); } diff --git a/tests/integration/completion_variables.rs b/tests/integration/completion_variables.rs index aa5e3d53..df0fbbad 100644 --- a/tests/integration/completion_variables.rs +++ b/tests/integration/completion_variables.rs @@ -408,8 +408,8 @@ async fn test_completion_new_self_variable() { "Should include non-static 'build'" ); assert!( - !method_names.contains(&"create"), - "Should exclude static 'create' via ->" + method_names.contains(&"create"), + "Should include static 'create' via -> (PHP allows static calls via instance)" ); } _ => panic!("Expected CompletionResponse::Array"),