diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cc2b4874..dad4adb9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Remove `#[Override]` code action.** When PHPStan reports `method.override`, `property.override`, or `property.overrideAttribute` (the attribute is present but the member does not actually override anything, or `#[Override]` on properties is not supported in the current PHP version), a quickfix removes the `#[Override]` attribute. If the attribute shares a line with other attributes, only the `Override` token is removed. The diagnostic is eagerly cleared once the attribute is gone. - **Add `#[\ReturnTypeWillChange]` code action.** When PHPStan reports `method.tentativeReturnType`, a quickfix inserts `#[\ReturnTypeWillChange]` above the method declaration with correct indentation. The diagnostic is eagerly cleared once the attribute is present. - **Simplify with null coalescing / null-safe operator.** Ternary expressions that guard against `null` are detected and a code action offers to rewrite them. `isset($x) ? $x : $default` and `$x !== null ? $x : $default` become `$x ?? $default`. `$x !== null ? $x->foo() : null` becomes `$x?->foo()` (PHP 8.0+ only). +- **Completion and signature help for `new self`, `new static`, and `new parent`.** Inside a class, typing `new sel` offers `self` and `static` as keyword completions with constructor parameter snippets. `parent` is offered when the class has a parent. Signature help triggers when typing inside the parentheses of `new self(`, `new static(`, or `new parent(`. Contributed by @RemcoSmitsDev in https://github.com/AJenbo/phpantom_lsp/pull/51. - **Fix PHPDoc type mismatch code actions.** When PHPStan reports that a `@return`, `@param`, or `@var` tag has a type incompatible with the native type hint (`return.phpDocType`, `parameter.phpDocType`, `property.phpDocType`), two quickfixes are offered: update the tag type to match the native type, or remove the tag entirely. The diagnostic is eagerly cleared after applying either fix. ### Changed diff --git a/src/completion/call_resolution.rs b/src/completion/call_resolution.rs index 07612ca1..629807a1 100644 --- a/src/completion/call_resolution.rs +++ b/src/completion/call_resolution.rs @@ -190,6 +190,20 @@ impl Backend { } } + /// Resolve class name keywords (`self`, `static`, `parent`) to actual + /// class names in the context of the current class. + fn resolve_class_name_keyword(class_name: &str, current_class: Option<&ClassInfo>) -> String { + match class_name { + "self" | "static" => current_class + .map(|c| c.name.clone()) + .unwrap_or_else(|| class_name.to_string()), + "parent" => current_class + .and_then(|c| c.parent_class.clone()) + .unwrap_or_else(|| class_name.to_string()), + _ => class_name.to_string(), + } + } + /// Build a [`ResolvedCallableTarget`] for a constructor call. /// /// Loads and merges the class, then extracts `__construct` parameters. @@ -265,11 +279,15 @@ impl Backend { match effective { // ── Constructor: `new ClassName` or `new ClassName()` ──── - SubjectExpr::NewExpr { class_name } => Self::resolve_constructor_callable( - class_name, - &class_loader, - &self.resolved_class_cache, - ), + SubjectExpr::NewExpr { class_name } => { + let resolved_class_name = + Self::resolve_class_name_keyword(class_name, rctx.current_class); + Self::resolve_constructor_callable( + &resolved_class_name, + &class_loader, + &self.resolved_class_cache, + ) + } // ── Instance method call: `$subject->method(…)` ───────── SubjectExpr::MethodCall { base, method } => { diff --git a/src/completion/handler.rs b/src/completion/handler.rs index d7328350..1554e31f 100644 --- a/src/completion/handler.rs +++ b/src/completion/handler.rs @@ -41,6 +41,7 @@ use crate::completion::class_completion::{ use crate::completion::named_args::{NamedArgContext, parse_existing_args}; use crate::docblock::types::PHPDOC_TYPE_KEYWORDS; use crate::symbol_map::SymbolKind; +use crate::types::ClassInfo; use crate::types::{CompletionTarget, FileContext}; use crate::util::{find_class_at_offset, position_to_byte_offset, position_to_offset}; @@ -1089,6 +1090,94 @@ impl Backend { // ─── Strategy: class / constant / function completion ──────────────── + /// Build completion item for class keywords (`self`, `static`, `parent`) + /// in `new` expression contexts. + /// + /// When the cursor is inside a class and typing `new s`, these keywords + /// should be offered alongside regular class names. If the current class + /// has a constructor, the completion includes parameter snippets. + fn build_class_keyword_completions( + &self, + prefix: &str, + current_class: Option<&ClassInfo>, + ) -> Vec { + let mut items = Vec::new(); + + let Some(current_class) = current_class else { + return items; + }; + + let prefix_lower = prefix.to_lowercase(); + + for keyword in ["self", "static"] { + if !keyword.starts_with(&prefix_lower) { + continue; + } + + let mut item = CompletionItem { + label: keyword.to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some("Instantiate current class".to_string()), + filter_text: Some(keyword.to_string()), + sort_text: Some(format!("0_{keyword}")), + ..CompletionItem::default() + }; + + // Add constructor snippet if available + if let Some(ctor) = current_class + .methods + .iter() + .find(|m| m.name == "__construct") + { + let snippet = + crate::completion::builder::build_callable_snippet(keyword, &ctor.parameters); + item.insert_text = Some(snippet); + item.insert_text_format = Some(InsertTextFormat::SNIPPET); + } else { + item.insert_text = Some(format!("{}()$0", keyword)); + item.insert_text_format = Some(InsertTextFormat::SNIPPET); + } + + items.push(item); + } + + // `parent` - reference the parent class + if "parent".starts_with(&prefix_lower) + && let Some(parent_name) = ¤t_class.parent_class + { + let mut item = CompletionItem { + label: "parent".to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some(format!("Instantiate parent class ({})", parent_name)), + filter_text: Some("parent".to_string()), + sort_text: Some("0_parent".to_string()), + ..CompletionItem::default() + }; + + // Try to load parent class and get its constructor + if let Some(parent_cls) = self.find_or_load_class(parent_name) { + if let Some(ctor) = parent_cls.methods.iter().find(|m| m.name == "__construct") { + let snippet = crate::completion::builder::build_callable_snippet( + "parent", + &ctor.parameters, + ); + item.insert_text = Some(snippet); + item.insert_text_format = Some(InsertTextFormat::SNIPPET); + } else { + item.insert_text = Some("parent()$0".to_string()); + item.insert_text_format = Some(InsertTextFormat::SNIPPET); + } + } else { + item.insert_text = Some("parent()$0".to_string()); + item.insert_text_format = Some(InsertTextFormat::SNIPPET); + } + + items.push(item); + } + + items + } + /// Try to offer class name, constant, and function completions. /// /// When there is no `->` or `::` operator, check whether the user is @@ -1327,14 +1416,25 @@ impl Backend { // instanceof), only class names are valid — skip constants // and functions. if class_ctx.is_class_only() { - if class_items.is_empty() { - return None; - } - let items = if paren_follows_cursor(content, position) { + let mut items = if paren_follows_cursor(content, position) { strip_snippet_parens(class_items) } else { class_items }; + + // For `new` expressions, also offer `self`, `static`, and `parent` + // keywords when inside a class. + if class_ctx.is_new() { + let cursor_offset = position_to_offset(content, position); + let current_class = find_class_at_offset(&ctx.classes, cursor_offset); + let keyword_items = self.build_class_keyword_completions(&partial, current_class); + items.extend(keyword_items); + } + + if items.is_empty() { + return None; + } + return Some(CompletionResponse::List(CompletionList { is_incomplete: class_incomplete, items, diff --git a/src/php_type.rs b/src/php_type.rs index 5842f2d6..c64c9579 100644 --- a/src/php_type.rs +++ b/src/php_type.rs @@ -354,7 +354,7 @@ impl PhpType { } } PhpType::Generic(name, args) if !args.is_empty() => { - let value = if name == "Generator" { + let value = if Self::short_name_of(name) == "Generator" { // Generator: value is // the 2nd param (index 1). When only one param is // given, treat it as the value type. diff --git a/tests/completion_callable_snippets.rs b/tests/completion_callable_snippets.rs index 2c214a53..f0e3dfec 100644 --- a/tests/completion_callable_snippets.rs +++ b/tests/completion_callable_snippets.rs @@ -408,10 +408,10 @@ async fn test_snippet_user_function_multiple_required() { // ─── `new ClassName` Snippet Tests ────────────────────────────────────────── -/// Non-namespaced classes are discovered via class_index (a file map), -/// so they get `Name()$0` without constructor params. +/// Non-namespaced classes in the same file are available via ast_map (source 2), +/// so they get constructor params included in the snippet. #[tokio::test] -async fn test_snippet_new_class_non_namespaced_gets_empty_parens() { +async fn test_snippet_new_class_non_namespaced_with_constructor_params() { let backend = create_test_backend(); let uri = Url::parse("file:///snip_new.php").unwrap(); let text = concat!( @@ -427,8 +427,8 @@ async fn test_snippet_new_class_non_namespaced_gets_empty_parens() { assert_eq!( item.insert_text.as_deref(), - Some("MoneyFactory()$0"), - "Non-namespaced class from class_index should get empty parens" + Some("MoneyFactory(${1:\\$amount})$0"), + "Non-namespaced class in same file has constructor info available" ); assert_eq!(item.insert_text_format, Some(InsertTextFormat::SNIPPET)); } @@ -677,8 +677,8 @@ async fn test_snippet_new_inside_method_same_namespace() { assert_eq!(item.insert_text_format, Some(InsertTextFormat::SNIPPET)); } -/// `new ClassName` for a non-namespaced class (class_index source) gets -/// just empty parens since we don't do extra lookups for file maps. +/// `new ClassName` for a non-namespaced class in the same file has +/// constructor info available via ast_map (source 2). #[tokio::test] async fn test_snippet_new_inside_method_non_namespaced() { let backend = create_test_backend(); @@ -700,8 +700,8 @@ async fn test_snippet_new_inside_method_non_namespaced() { assert_eq!( item.insert_text.as_deref(), - Some("Logger()$0"), - "Non-namespaced class from class_index gets empty parens" + Some("Logger(${1:\\$channel})$0"), + "Non-namespaced class in same file has constructor info available" ); assert_eq!(item.insert_text_format, Some(InsertTextFormat::SNIPPET)); } @@ -1070,3 +1070,184 @@ async fn test_snippet_suppressed_for_static_call_when_parens_follow() { "should not use snippet format for static call when parens already follow" ); } + +// ─── Class Keywords (self, static, parent) in new expressions ─────────────── + +/// `new self` should be offered with constructor snippet when inside a class. +#[tokio::test] +async fn test_snippet_new_self_with_constructor() { + let backend = create_test_backend(); + let uri = Url::parse("file:///new_self.php").unwrap(); + let text = concat!( + "