Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/diagnostics/invalid_class_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,10 @@ impl Backend {
_ => continue,
};

// Only check references with a known context.
if ref_ctx == ClassRefContext::Other {
// Only check references with a known context. Attribute
// usages (`#[Foo]`) are valid on any instantiable class, so
// they are skipped just like `Other`.
if ref_ctx == ClassRefContext::Other || ref_ctx == ClassRefContext::Attribute {
continue;
}

Expand Down Expand Up @@ -346,7 +348,7 @@ fn check_kind_in_context(
None
}
}
ClassRefContext::Other | ClassRefContext::UseImport => None,
ClassRefContext::Other | ClassRefContext::UseImport | ClassRefContext::Attribute => None,
}
}

Expand Down
214 changes: 213 additions & 1 deletion src/references/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use std::sync::Arc;
use tower_lsp::lsp_types::{Location, Position, Range, Url};

use crate::Backend;
use crate::symbol_map::{SelfStaticParentKind, SymbolKind, SymbolMap, VarDefKind};
use crate::symbol_map::{ClassRefContext, SelfStaticParentKind, SymbolKind, SymbolMap, VarDefKind};
use crate::types::ClassInfo;
use crate::util::{
build_fqn, collect_php_files_gitignore, find_class_at_offset, offset_to_position,
Expand Down Expand Up @@ -185,6 +185,27 @@ impl Backend {
member_name,
);

// Constructors are not invoked through member accesses
// (`$obj->__construct()`); they are invoked through
// `new ClassName(...)`. An explicit `parent::__construct()`
// call still lands here, so route to the constructor finder
// seeded with the subject's resolved class(es).
if is_constructor_name(member_name) {
let seeds = self
.get_file_content(uri)
.map(|content| {
self.resolve_subject_to_fqns(
subject_text,
*is_static,
&self.file_context(uri),
span_start,
&content,
)
})
.unwrap_or_default();
return self.find_constructor_references(&seeds, include_declaration);
}

self.find_member_references(
member_name,
*is_static,
Expand All @@ -201,6 +222,19 @@ impl Backend {
self.find_constant_references(name, include_declaration)
}
SymbolKind::MemberDeclaration { name, is_static } => {
// A constructor declaration's "references" are the
// `new ClassName(...)` instantiation sites (and `#[...]`
// attribute usages), not `->__construct()` member accesses
// (which don't exist in normal PHP code).
if is_constructor_name(name) {
let ctx = self.file_context(uri);
let seeds: Vec<String> =
crate::util::find_class_at_offset(&ctx.classes, span_start)
.map(|cc| vec![build_fqn(&cc.name, ctx.namespace.as_deref())])
.unwrap_or_default();
return self.find_constructor_references(&seeds, include_declaration);
}

// Resolve the enclosing class to scope the search.
let hierarchy =
self.resolve_member_declaration_hierarchy(uri, span_start, name, *is_static);
Expand Down Expand Up @@ -803,6 +837,177 @@ impl Backend {
locations
}

/// Find all references to a constructor (`__construct`).
///
/// Unlike ordinary methods, constructors are not invoked through
/// member-access syntax (`$obj->__construct()`); the call sites are
/// `new ClassName(...)` instantiation expressions plus explicit
/// `parent::__construct()` / `self::__construct()` style calls.
///
/// `owner_fqns` are the class(es) that declare the constructor under
/// the cursor. A `new SubClass()` expression only invokes this
/// constructor when `SubClass` inherits it (i.e. does not declare its
/// own), so the search scope is expanded to inheriting descendants and
/// pruned at overriding ones (see
/// [`Self::collect_constructor_hierarchy`]).
fn find_constructor_references(
&self,
owner_fqns: &[String],
include_declaration: bool,
) -> Vec<Location> {
if owner_fqns.is_empty() {
return Vec::new();
}

// Expand the owners to the set of classes whose instantiation
// invokes this same constructor (inheriting descendants), pruning
// at descendants that override it.
let scoped = self.collect_constructor_hierarchy(owner_fqns);
if scoped.is_empty() {
return Vec::new();
}

let mut locations = Vec::new();
let snapshot = self.user_file_symbol_maps();

for (file_uri, symbol_map) in &snapshot {
let resolved_names = self.resolved_names.read().get(file_uri).cloned();
let file_namespace = self.first_file_namespace(file_uri);
let file_use_map = std::cell::OnceCell::new();

let Some(parsed_uri) = Url::parse(file_uri).ok() else {
continue;
};

let mut file_content: Option<Arc<String>> = None;

for span in &symbol_map.spans {
let matched = match &span.kind {
// `new ClassName(...)` carries `ClassRefContext::New`;
// `#[ClassName(...)]` attribute usages carry
// `ClassRefContext::Attribute`. Both invoke the
// constructor.
SymbolKind::ClassReference {
name,
is_fqn,
context: ClassRefContext::New | ClassRefContext::Attribute,
} => {
let resolved = if *is_fqn {
name
} else if let Some(fqn) =
resolved_names.as_ref().and_then(|rn| rn.get(span.start))
{
fqn
} else {
let use_map = file_use_map.get_or_init(|| {
self.file_imports
.read()
.get(file_uri)
.cloned()
.unwrap_or_default()
});
&Self::resolve_to_fqn(name, use_map, &file_namespace)
};
scoped.contains(&normalize_fqn(strip_fqn_prefix(resolved)))
}
_ => false,
};

if matched {
if file_content.is_none() {
file_content = self.get_file_content_arc(file_uri);
}
if let Some(content) = &file_content {
let start = offset_to_position(content, span.start as usize);
let end = offset_to_position(content, span.end as usize);
push_unique_location(&mut locations, &parsed_uri, start, end);
}
}
}

// Optionally include the constructor declaration site(s).
if include_declaration && let Some(classes) = self.get_classes_for_uri(file_uri) {
for class in &classes {
let class_fqn = normalize_fqn(&class.fqn()).to_string();
if !scoped.contains(&class_fqn) {
continue;
}

for method in class.methods.iter() {
if is_constructor_name(&method.name) && method.name_offset != 0 {
if file_content.is_none() {
file_content = self.get_file_content_arc(file_uri);
}
let Some(content) = &file_content else {
break;
};
let offset = method.name_offset as usize;
let start = offset_to_position(content, offset);
let end = offset_to_position(content, offset + method.name.len());
push_unique_location(&mut locations, &parsed_uri, start, end);
}
}
}
}
}

locations.sort_by(|a, b| {
a.uri
.as_str()
.cmp(b.uri.as_str())
.then(a.range.start.line.cmp(&b.range.start.line))
.then(a.range.start.character.cmp(&b.range.start.character))
});
locations.dedup();
locations
}

/// Expand the constructor owner class(es) into the full set of classes
/// whose instantiation (`new X(...)`) invokes the same constructor.
///
/// Starting from `owner_fqns` (the class(es) that declare the
/// constructor under the cursor), walk down the inheritance tree and
/// include every descendant that does *not* declare its own
/// constructor (those inherit the owner's), pruning the walk at any
/// descendant that overrides it.
fn collect_constructor_hierarchy(&self, owner_fqns: &[String]) -> HashSet<String> {
let class_loader = |name: &str| -> Option<Arc<ClassInfo>> { self.find_or_load_class(name) };
let declares_ctor = |fqn: &str| -> bool {
class_loader(fqn)
.map(|c| c.methods.iter().any(|m| is_constructor_name(&m.name)))
.unwrap_or(false)
};

let owners: Vec<String> = owner_fqns.iter().map(|f| normalize_fqn(f)).collect();
let mut result: HashSet<String> = owners.iter().cloned().collect();

// Walk down from each owner, including inheriting descendants and
// pruning at overrides.
let gti = self.gti_index.read();
let mut queue: std::collections::VecDeque<String> = owners.iter().cloned().collect();
let mut seen: HashSet<String> = owners.iter().cloned().collect();
while let Some(fqn) = queue.pop_front() {
if let Some(descendants) = gti.get(&fqn) {
for desc in descendants {
let normalized = normalize_fqn(desc).to_string();
if !seen.insert(normalized.clone()) {
continue;
}
// A descendant that declares its own constructor uses a
// different constructor — exclude it and stop walking
// past it.
if declares_ctor(&normalized) {
continue;
}
result.insert(normalized.clone());
queue.push_back(normalized);
}
}
}

result
}

/// Find all references to a member (method, property, or constant)
/// across all files.
///
Expand Down Expand Up @@ -1791,6 +1996,13 @@ fn normalize_fqn(fqn: &str) -> String {
strip_fqn_prefix(fqn).to_string()
}

/// Whether a member name is the PHP constructor (`__construct`).
///
/// PHP method names are case-insensitive, so `__CONSTRUCT` matches too.
fn is_constructor_name(name: &str) -> bool {
name.eq_ignore_ascii_case("__construct")
}

/// Check whether a resolved class name matches the target FQN.
///
/// Two names match if their fully-qualified forms are equal, or if both
Expand Down
119 changes: 119 additions & 0 deletions src/references/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1689,3 +1689,122 @@ async fn test_phpdoc_method_no_return_type_references() {
"Should include the @method declaration on line 2"
);
}

// ─── Constructor References ──────────────────────────────────────────

#[tokio::test]
async fn test_constructor_references_finds_instantiations() {
let backend = Backend::new_test();
let uri = Url::parse("file:///ctor.php").unwrap();
let text = concat!(
"<?php\n", // L0
"class Service {\n", // L1
" public function __construct() {}\n", // L2
"}\n", // L3
"$a = new Service();\n", // L4
"$b = new Service();\n", // L5
);

open_file(&backend, &uri, text).await;

// Click on "__construct" at line 2.
let locs = find_references(&backend, &uri, 2, 25, true).await;

// Both `new Service()` sites should be found.
let has_l4 = locs.iter().any(|l| l.range.start.line == 4);
let has_l5 = locs.iter().any(|l| l.range.start.line == 5);
assert!(
has_l4 && has_l5,
"Expected both `new Service()` instantiations (L4 + L5), got {:?}",
locs
);
}

#[tokio::test]
async fn test_constructor_references_includes_inheriting_subclass() {
let backend = Backend::new_test();
let uri = Url::parse("file:///ctor_inherit.php").unwrap();
let text = concat!(
"<?php\n", // L0
"class Base {\n", // L1
" public function __construct() {}\n", // L2
"}\n", // L3
"class Child extends Base {}\n", // L4
"$a = new Base();\n", // L5
"$b = new Child();\n", // L6
);

open_file(&backend, &uri, text).await;

// Click on "__construct" at line 2.
let locs = find_references(&backend, &uri, 2, 25, true).await;

// `new Child()` inherits Base's constructor, so it counts.
let has_base = locs.iter().any(|l| l.range.start.line == 5);
let has_child = locs.iter().any(|l| l.range.start.line == 6);
assert!(
has_base && has_child,
"Expected `new Base()` (L5) and inherited `new Child()` (L6), got {:?}",
locs
);
}

#[tokio::test]
async fn test_constructor_references_excludes_overriding_subclass() {
let backend = Backend::new_test();
let uri = Url::parse("file:///ctor_override.php").unwrap();
let text = concat!(
"<?php\n", // L0
"class Base {\n", // L1
" public function __construct() {}\n", // L2
"}\n", // L3
"class Child extends Base {\n", // L4
" public function __construct() {}\n", // L5
"}\n", // L6
"$a = new Base();\n", // L7
"$b = new Child();\n", // L8
);

open_file(&backend, &uri, text).await;

// Click on Base's "__construct" at line 2.
let locs = find_references(&backend, &uri, 2, 25, true).await;

// `new Child()` invokes Child's OWN constructor, so it must be excluded.
let has_base = locs.iter().any(|l| l.range.start.line == 7);
let has_child = locs.iter().any(|l| l.range.start.line == 8);
assert!(has_base, "Expected `new Base()` (L7), got {:?}", locs);
assert!(
!has_child,
"`new Child()` (L8) overrides the constructor and must be excluded, got {:?}",
locs
);
}

#[tokio::test]
async fn test_constructor_references_finds_attribute_usage() {
let backend = Backend::new_test();
let uri = Url::parse("file:///ctor_attr.php").unwrap();
let text = concat!(
"<?php\n", // L0
"#[\\Attribute]\n", // L1
"class MyAttr {\n", // L2
" public function __construct(int $x = 0) {}\n", // L3
"}\n", // L4
"#[MyAttr(1)]\n", // L5
"class Target {}\n", // L6
);

open_file(&backend, &uri, text).await;

// Click on MyAttr's "__construct" at line 3.
let locs = find_references(&backend, &uri, 3, 25, true).await;

// The `#[MyAttr(1)]` attribute usage on line 5 invokes the constructor.
let has_attr_usage = locs.iter().any(|l| l.range.start.line == 5);
assert!(
has_attr_usage,
"Expected the `#[MyAttr(1)]` attribute usage (L5) to be a constructor reference, got {:?}",
locs
);
}
Loading
Loading