Skip to content

Commit 1928849

Browse files
committed
feat: add reference count inlay hints
Show reference counts for classes, enums, interfaces, traits, methods, properties, and constants as inlay hints instead of non-actionable code lenses. Interface and abstract class declarations also show implementation counts. Code lenses remain reserved for actionable prototype navigation. The hints omit private members, magic methods, and overridden members to avoid noisy or misleading counts, while still showing zero-reference hints so users can distinguish unused symbols from missing data. Go-to-definition on overridden declarations now follows methods, properties, and constants to the nearest parent, trait, or interface prototype where applicable. Use a single all-targets Clippy invocation in CI and let agent docs prefer clippy --fix so automated changes apply safe lint suggestions before formatting. Closes #140
1 parent 6348c6c commit 1928849

7 files changed

Lines changed: 626 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ jobs:
3434
mkdir -p .cargo
3535
printf '[target.%s]\nrustflags = ["-C", "link-arg=-fuse-ld=mold"]\n' "$CARGO_BUILD_TARGET" >> .cargo/config.toml
3636
- run: cargo fmt --check
37-
- run: cargo clippy -- -D warnings
38-
- run: cargo clippy --tests -- -D warnings
37+
- run: cargo clippy --all-targets -- -D warnings
3938

4039
test:
4140
name: Test

AGENTS.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,11 @@ round it out.
5353
Always run these checks before considering any change complete:
5454

5555
```bash
56-
cargo clippy -- -D warnings
57-
cargo clippy --tests -- -D warnings
56+
cargo clippy --fix --allow-dirty -- -D warnings
5857
cargo fmt
5958
```
6059

61-
Clippy runs twice: once for library code, once including tests. Run
62-
`cargo fmt` after clippy, not before -- clippy fixes can affect
60+
Run `cargo fmt` after clippy, not before -- clippy fixes can affect
6361
formatting.
6462

6563
## Contributing Guidelines

docs/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- **`@phpstan-ignore` identifiers are highlighted and completed.** PHPStan ignore comments now highlight the `@phpstan-ignore` tag and each listed error identifier in both docblocks and ordinary `//` comments. Identifier completion works inside the comma-separated ignore list, using PHPStan diagnostic codes already seen in the current file while staying out of per-code parenthesized reasons. Contributed by @calebdw.
1313
- **Parent member override completion.** Typing a method name after `function` in a class body (for example `protected function get`) now suggests public and protected methods from parent classes and interfaces that can still be overridden or implemented, inserting a full signature snippet. The same flow suggests parent properties after `$` (for example `protected $tit`) and parent constants after `const`. Snippets insert `#[\Override]` above methods on PHP 8.3+, properties on PHP 8.5+, and constants on PHP 8.6+ (from `composer.json` / `config.platform.php`). Private members and ones already defined on the class are omitted. Contributed by @calebdw.
14+
- **Reference and implementation count inlay hints.** Classes, interfaces, traits, enums, methods, properties, and constants now show reference counts (e.g. "3 references") as inlay hints. Interfaces and abstract classes additionally show implementation counts (e.g. "2 implementations"). Counts are derived from the cross-file reference index and the go-to-implementation reverse inheritance index, so they are fast even on large codebases. Private members, magic methods, and overridden members are omitted to keep the annotations focused. Contributed by @calebdw.
1415
- **Trait member override completion.** Typing a method or property name after `function` or `$` in a class that uses a trait now suggests the trait's public and protected members as override candidates. Unlike parent/interface overrides, trait method replacements do not insert `#[\Override]` (which would be a compile error for trait-only methods). Classes that only use traits without extending a parent or implementing an interface also receive suggestions. Contributed by @calebdw.
1516
- **Laravel schema dumps power Eloquent model properties.** PHPantom now scans Laravel database schema dumps from `database/schema` by default, reads `config/database.php` for connection drivers/defaults, and uses the parsed columns to synthesize Eloquent model properties with database types, nullability, and defaults in hover. Schema lookup respects model `$connection`, `$table`, Laravel `Connection`/`Table` attributes, dynamic table/connection overrides, and reloads when schema files or related config change. Contributed by @calebdw.
1617
- **Laravel migration scanning for Eloquent model properties.** PHPantom now parses Laravel migration files to infer database columns when schema dumps are not available or to overlay changes on top of dumps. Migrations are discovered from any non-vendor `database/migrations` directory (including nested modules like `modules/billing/database/migrations`), applied in global filename order, and support named and anonymous migration classes, `$connection` properties, `Schema::connection()` calls, `Blueprint::after()` nested closures, `virtualAs`/`storedAs` generated columns, and custom Blueprint macros registered via the existing macro scanner. Migration scanning is incremental: editing a single migration re-reads only that file and replays the cached plan over the base schema without re-reading other files. Configure with `[laravel.migrations] enabled` and `paths` in `.phpantom.toml`. Contributed by @calebdw.
@@ -42,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4243
### Fixed
4344

4445
- **Go-to-definition on overriding methods jumps to the parent declaration.** When the cursor is on a method definition that overrides a parent or implements an interface method, go-to-definition now navigates to the prototype declaration instead of returning call-site references. Similarly, go-to-definition on a class name jumps to the parent class when one exists. Methods and classes that don't override anything still show usages as before. Contributed by @calebdw.
46+
- **Go-to-definition on overridden properties and constants jumps to the prototype declaration.** Declaration-site navigation now follows overridden properties and constants to the nearest parent or trait declaration, matching the override behavior for methods. Contributed by @calebdw.
4547
- **Code lens navigation works on Cursor, VSCodium, Neovim, and other editors.** Clicking a code lens annotation (e.g. "overrides Parent::method") now uses the standard `window/showDocument` LSP request instead of editor-specific commands (`vscode.open`, `editor.action.showReferences`) that only worked on VS Code. Contributed by @calebdw.
4648
- **Go-to-implementation works when interface and class share the same short name.** An interface and its implementing class in different namespaces but with the same class name (e.g. `App\Contracts\HttpClient` and `App\Foo\HttpClient`) now resolves correctly instead of returning no results. Contributed by @calebdw.
4749
- **Static method calls resolve return types as accurately as instance calls.** `Foo::bar()` previously missed inference that `$foo->bar()` already had: return types behind a `@phpstan-type` alias, inherited return types substituted through a generic interface or trait, and the `__callStatic()` magic-method fallback. These now resolve the same way for both call styles.

docs/CONTRIBUTING.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ All six CI checks must pass with zero warnings and zero failures:
1414

1515
```bash
1616
cargo test
17-
cargo clippy -- -D warnings
18-
cargo clippy --tests -- -D warnings
17+
cargo clippy --all-targets -- -D warnings
1918
cargo fmt --check
2019
php -l examples/demo.php
2120
php -d zend.assertions=1 examples/demo.php

src/definition/resolve.rs

Lines changed: 229 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,25 @@ use std::sync::Arc;
1818
use crate::symbol_map::VarDefKind;
1919
use tower_lsp::lsp_types::*;
2020

21-
use super::member::{MemberAccessHint, MemberDefinitionCtx};
21+
use super::member::{MemberAccessHint, MemberDefinitionCtx, MemberKind};
2222
use super::point_location;
2323
use crate::Backend;
2424
use crate::class_lookup::find_class_at_offset;
2525
use crate::composer;
2626
use crate::symbol_map::{SelfStaticParentKind, SymbolKind};
2727
use crate::text_position::position_to_offset;
28-
use crate::types::{AccessKind, ClassInfo};
28+
use crate::types::{AccessKind, ClassInfo, MAX_INHERITANCE_DEPTH};
2929
use crate::util::short_name;
3030
use crate::virtual_members::laravel;
3131

32+
struct MemberPrototypeSearch<'a> {
33+
member_name: &'a str,
34+
kind: MemberKind,
35+
uri: &'a str,
36+
content: &'a str,
37+
class_loader: &'a dyn Fn(&str) -> Option<Arc<ClassInfo>>,
38+
}
39+
3240
impl Backend {
3341
/// Handle a "go to definition" request.
3442
///
@@ -266,13 +274,27 @@ impl Backend {
266274
.resolve_class_reference(uri, content, name, *is_fqn, cursor_offset)
267275
.map(|loc| vec![loc]),
268276

269-
SymbolKind::MemberDeclaration { name, .. } => {
277+
SymbolKind::MemberDeclaration { name, is_static } => {
270278
// If this method/property overrides a parent or implements
271279
// an interface member, jump to the prototype declaration.
272280
let ctx = self.file_context(uri);
273281
let class_loader = self.class_loader(&ctx);
274282
let current_class =
275283
crate::class_lookup::find_class_at_offset(&ctx.classes, cursor_offset);
284+
if let Some(cls) = current_class
285+
&& let Some(kind) = self.infer_member_declaration_kind(cls, name, *is_static)
286+
&& let Some(loc) = self.resolve_member_declaration_prototype(
287+
uri,
288+
content,
289+
cls,
290+
name,
291+
kind,
292+
&class_loader,
293+
)
294+
{
295+
return Some(vec![loc]);
296+
}
297+
276298
if let Some(cls) = current_class
277299
&& let Some(locs) =
278300
self.resolve_reverse_implementation(uri, content, cls, name, &class_loader)
@@ -355,6 +377,210 @@ impl Backend {
355377
}
356378
}
357379

380+
fn infer_member_declaration_kind(
381+
&self,
382+
class: &ClassInfo,
383+
member_name: &str,
384+
is_static: bool,
385+
) -> Option<MemberKind> {
386+
if is_static
387+
&& class
388+
.constants
389+
.iter()
390+
.any(|c| c.name == member_name && c.visibility != crate::types::Visibility::Private)
391+
{
392+
return Some(MemberKind::Constant);
393+
}
394+
395+
if class.methods.iter().any(|m| {
396+
m.name == member_name
397+
&& m.is_static == is_static
398+
&& !m.is_virtual
399+
&& m.visibility != crate::types::Visibility::Private
400+
}) {
401+
return Some(MemberKind::Method);
402+
}
403+
404+
if class.properties.iter().any(|p| {
405+
p.name == member_name
406+
&& p.is_static == is_static
407+
&& !p.is_virtual
408+
&& p.visibility != crate::types::Visibility::Private
409+
}) {
410+
return Some(MemberKind::Property);
411+
}
412+
413+
None
414+
}
415+
416+
fn resolve_member_declaration_prototype(
417+
&self,
418+
uri: &str,
419+
content: &str,
420+
class: &ClassInfo,
421+
member_name: &str,
422+
kind: MemberKind,
423+
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
424+
) -> Option<Location> {
425+
let search = MemberPrototypeSearch {
426+
member_name,
427+
kind,
428+
uri,
429+
content,
430+
class_loader,
431+
};
432+
433+
if let Some(loc) = self.find_member_prototype_in_traits(&class.used_traits, &search, 0) {
434+
return Some(loc);
435+
}
436+
437+
let mut current = class.clone();
438+
for _ in 0..MAX_INHERITANCE_DEPTH {
439+
let Some(parent_name) = current.parent_class else {
440+
break;
441+
};
442+
let Some(parent) = class_loader(&parent_name).map(Arc::unwrap_or_clone) else {
443+
break;
444+
};
445+
446+
if self.class_declares_member(&parent, &search)
447+
&& let Some(loc) = self.member_location(&parent_name, &parent, &search)
448+
{
449+
return Some(loc);
450+
}
451+
452+
if let Some(loc) = self.find_member_prototype_in_traits(&parent.used_traits, &search, 0)
453+
{
454+
return Some(loc);
455+
}
456+
457+
current = parent;
458+
}
459+
460+
if matches!(search.kind, MemberKind::Method | MemberKind::Constant) {
461+
return self.find_member_prototype_in_interfaces(class, &search);
462+
}
463+
464+
None
465+
}
466+
467+
fn find_member_prototype_in_traits(
468+
&self,
469+
trait_names: &[crate::atom::Atom],
470+
search: &MemberPrototypeSearch<'_>,
471+
depth: usize,
472+
) -> Option<Location> {
473+
if depth > MAX_INHERITANCE_DEPTH as usize {
474+
return None;
475+
}
476+
477+
for trait_name in trait_names {
478+
let Some(trait_info) = (search.class_loader)(trait_name).map(Arc::unwrap_or_clone)
479+
else {
480+
continue;
481+
};
482+
if self.class_declares_member(&trait_info, search)
483+
&& let Some(loc) = self.member_location(trait_name, &trait_info, search)
484+
{
485+
return Some(loc);
486+
}
487+
if let Some(loc) =
488+
self.find_member_prototype_in_traits(&trait_info.used_traits, search, depth + 1)
489+
{
490+
return Some(loc);
491+
}
492+
}
493+
494+
None
495+
}
496+
497+
fn find_member_prototype_in_interfaces(
498+
&self,
499+
class: &ClassInfo,
500+
search: &MemberPrototypeSearch<'_>,
501+
) -> Option<Location> {
502+
let mut current = Some(class.clone());
503+
for _ in 0..MAX_INHERITANCE_DEPTH {
504+
let cls = current?;
505+
for iface_name in &cls.interfaces {
506+
if let Some(loc) = self.find_member_prototype_in_interface(iface_name, search, 0) {
507+
return Some(loc);
508+
}
509+
}
510+
current = cls
511+
.parent_class
512+
.as_deref()
513+
.and_then(|parent| (search.class_loader)(parent).map(Arc::unwrap_or_clone));
514+
}
515+
516+
None
517+
}
518+
519+
fn find_member_prototype_in_interface(
520+
&self,
521+
iface_name: &str,
522+
search: &MemberPrototypeSearch<'_>,
523+
depth: usize,
524+
) -> Option<Location> {
525+
if depth > MAX_INHERITANCE_DEPTH as usize {
526+
return None;
527+
}
528+
let iface = (search.class_loader)(iface_name).map(Arc::unwrap_or_clone)?;
529+
if self.class_declares_member(&iface, search)
530+
&& let Some(loc) = self.member_location(iface_name, &iface, search)
531+
{
532+
return Some(loc);
533+
}
534+
535+
for parent in &iface.interfaces {
536+
if let Some(loc) = self.find_member_prototype_in_interface(parent, search, depth + 1) {
537+
return Some(loc);
538+
}
539+
}
540+
541+
if let Some(parent) = iface.parent_class
542+
&& let Some(loc) = self.find_member_prototype_in_interface(&parent, search, depth + 1)
543+
{
544+
return Some(loc);
545+
}
546+
547+
None
548+
}
549+
550+
fn class_declares_member(&self, class: &ClassInfo, search: &MemberPrototypeSearch<'_>) -> bool {
551+
match search.kind {
552+
MemberKind::Method => class.methods.iter().any(|m| {
553+
m.name == search.member_name
554+
&& !m.is_virtual
555+
&& m.visibility != crate::types::Visibility::Private
556+
}),
557+
MemberKind::Property => class.properties.iter().any(|p| {
558+
p.name == search.member_name
559+
&& !p.is_virtual
560+
&& p.visibility != crate::types::Visibility::Private
561+
}),
562+
MemberKind::Constant => class.constants.iter().any(|c| {
563+
c.name == search.member_name && c.visibility != crate::types::Visibility::Private
564+
}),
565+
}
566+
}
567+
568+
fn member_location(
569+
&self,
570+
class_name: &str,
571+
class: &ClassInfo,
572+
search: &MemberPrototypeSearch<'_>,
573+
) -> Option<Location> {
574+
let offset = class.member_name_offset(search.member_name, search.kind.as_str())?;
575+
let (target_uri, target_content) =
576+
self.find_class_file_content(class_name, search.uri, search.content)?;
577+
let parsed_uri = Url::parse(&target_uri).ok()?;
578+
Some(point_location(
579+
parsed_uri,
580+
crate::text_position::offset_to_position(&target_content, offset as usize),
581+
))
582+
}
583+
358584
/// Resolve a `ClassReference` symbol to its definition.
359585
///
360586
/// Tries same-file lookup (uri_classes_index), then cross-file via PSR-4.

src/diagnostics/workspace.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,6 @@ impl Backend {
331331
last_delivery = Instant::now();
332332
}
333333
}
334-
335334
done
336335
}
337336

0 commit comments

Comments
 (0)