Skip to content

Commit a099ec8

Browse files
committed
Fix throw/catch completion to include vendor Throwable classes
1 parent 195a5da commit a099ec8

3 files changed

Lines changed: 114 additions & 266 deletions

File tree

docs/CHANGELOG.md

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

6464
### Fixed
6565

66+
- **`throw new` completion missing vendor classes.** Classes whose Throwable ancestry could not be immediately verified (e.g. vendor classes not yet parsed) were silently excluded from `throw new` and `catch` completion, even though later heuristic-based sections should have included them.
6667
- **Stale mixin members after editing.** Mixin class resolution (e.g. `@mixin Builder`) is now invalidated when any file changes, so newly added or removed methods on mixin targets appear immediately without restarting the server.
6768
- **Version-gated stub constants now filtered.** Constants with `@removed` tags (e.g. `MCRYPT_ENCRYPT`, removed in PHP 7.2) are now excluded from completion and resolution when the project targets a newer PHP version. Previously only classes and functions were filtered.
6869
- **Go-to-definition.** Fixed a potential deadlock when navigating to a vendor class that hadn't been parsed yet.

src/completion/context/catch_completion.rs

Lines changed: 76 additions & 227 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ use crate::util::{short_name, strip_fqn_prefix};
2929

3030
use super::class_completion::{
3131
ClassItemCtx, ClassItemTexts, build_affinity_table, class_edit_texts, expand_alias_prefix,
32-
is_anonymous_class, matches_class_prefix,
32+
matches_class_prefix,
3333
};
3434
use crate::completion::builder::analyze_use_block;
3535
use crate::completion::source::comment_position::position_to_byte_offset;
@@ -420,55 +420,21 @@ impl Backend {
420420
}
421421
}
422422

423-
/// Check whether the class identified by `class_name` is a class or
424-
/// interface in the `ast_map` (i.e. not a trait or enum).
423+
/// Build completion items for class names suitable for `throw new`
424+
/// and `catch` contexts.
425425
///
426-
/// Used by catch-clause completion to allow both concrete classes,
427-
/// abstract classes, and interfaces (e.g. `\Throwable` itself is an
428-
/// interface, and `catch (\Throwable $e)` is idiomatic PHP).
426+
/// Every matching class from `fqn_uri_index` and `stub_index` is
427+
/// included exactly once. Sort priority is determined by:
429428
///
430-
/// Returns `false` for traits, enums, and classes that are not
431-
/// currently loaded. This never triggers disk I/O.
432-
fn is_class_or_interface_in_ast_map(&self, class_name: &str) -> bool {
433-
self.find_class_in_ast_map(class_name)
434-
.is_some_and(|c| matches!(c.kind, ClassLikeKind::Class | ClassLikeKind::Interface))
435-
}
436-
437-
/// Collect the FQN of every class that is currently loaded in the
438-
/// `ast_map`. Used by `build_catch_class_name_completions` so that
439-
/// class index / stub sources can skip classes we already evaluated.
440-
fn collect_loaded_fqns(&self) -> HashSet<String> {
441-
let mut loaded = HashSet::new();
442-
let amap = self.uri_classes_index.read();
443-
for (_uri, classes) in amap.iter() {
444-
for cls in classes {
445-
let fqn = match &cls.file_namespace {
446-
Some(ns) if !ns.is_empty() => format!("{}\\{}", ns, cls.name),
447-
_ => cls.name.to_string(),
448-
};
449-
loaded.insert(fqn);
450-
}
451-
}
452-
loaded
453-
}
454-
455-
/// Build completion items for class names, filtered for Throwable
456-
/// descendants. Used as the catch clause fallback when no specific
457-
/// `@throws` types were discovered in the try block, and for
458-
/// `throw new` completion.
459-
///
460-
/// The logic follows this priority:
429+
/// - **Source tier `'0'`** — use-imported and confirmed Throwable.
430+
/// - **Source tier `'1'`** — same namespace (or sub-namespace) and
431+
/// confirmed Throwable.
432+
/// - **Source tier `'2'`** — everything else (confirmed Throwable
433+
/// from other namespaces, or unloaded classes).
461434
///
462-
/// 1. **Loaded classes and interfaces** (use-imports, same-namespace,
463-
/// class_index): only classes and interfaces (not traits/enums)
464-
/// whose parent/interface chain is fully walkable to `\Throwable`
465-
/// / `\Exception` / `\Error`.
466-
/// 2. **Class index** entries (not yet parsed) whose short name ends
467-
/// with `Exception` — filtered to exclude already-loaded FQNs.
468-
/// 3. **Stub** entries whose short name ends with `Exception` —
469-
/// filtered to exclude already-loaded FQNs.
470-
/// 4. **Class index** entries that do *not* end with `Exception`.
471-
/// 5. **Stub** entries that do *not* end with `Exception`.
435+
/// Within tier `'2'`, classes whose short name does *not* end with
436+
/// `Exception`, `Error`, or `Throwable` are demoted so that likely
437+
/// exception classes sort first.
472438
pub(crate) fn build_catch_class_name_completions(
473439
&self,
474440
ctx: &crate::types::FileContext,
@@ -538,133 +504,62 @@ impl Backend {
538504
uri,
539505
};
540506

541-
// Build the set of every FQN currently in the ast_map so that
542-
// class index / stub sources can exclude already-evaluated classes.
543-
let loaded_fqns = self.collect_loaded_fqns();
544-
545-
// ── 1a. Use-imported classes/interfaces (must be Throwable) ─
546-
for (short_name, fqn) in file_use_map {
547-
if !matches_class_prefix(
548-
short_name,
549-
fqn,
550-
&prefix_lower,
551-
is_fqn_prefix,
552-
expanded_prefix_lower,
553-
) {
554-
continue;
555-
}
556-
if !seen_fqns.insert(fqn.clone()) {
557-
continue;
558-
}
559-
// Only classes and interfaces (not traits/enums)
560-
if !self.is_class_or_interface_in_ast_map(fqn) {
561-
continue;
562-
}
563-
// Strict check: only include if confirmed Throwable descendant
564-
if !self.is_throwable_descendant(fqn, 0) {
565-
continue;
566-
}
567-
let (base_name, filter, _use_import) = class_edit_texts(
568-
short_name,
569-
fqn,
570-
is_fqn_prefix,
571-
has_leading_backslash,
572-
file_namespace,
573-
);
574-
let texts = ClassItemTexts {
575-
base_name,
576-
filter,
577-
use_import: None,
578-
};
579-
items.push(ctx.build_item(texts, fqn, '0', false, None, false));
580-
}
581-
582-
// ── 1b. Same-namespace classes (must be concrete + Throwable)
583-
// Collect candidates while holding the lock, then drop the lock
584-
// before calling `is_throwable_descendant` (which re-locks
585-
// `ast_map` internally — Rust's Mutex is not re-entrant).
586-
{
587-
let nmap = self.file_namespaces.read();
588-
let same_ns_uris: Vec<String> = nmap
589-
.iter()
590-
.filter_map(|(uri, spans)| {
591-
let has_ns = spans
592-
.iter()
593-
.any(|s| s.namespace.as_deref() == file_namespace.as_deref());
594-
if has_ns { Some(uri.clone()) } else { None }
595-
})
596-
.collect();
597-
drop(nmap);
598-
599-
// Phase 1: collect candidate (name, fqn, deprecation_message)
600-
// tuples under the ast_map lock — classes and interfaces only.
601-
let mut candidates: Vec<(String, String, Option<String>)> = Vec::new();
602-
{
603-
let amap = self.uri_classes_index.read();
604-
for uri in &same_ns_uris {
605-
if let Some(classes) = amap.get(uri) {
606-
for cls in classes {
607-
if is_anonymous_class(&cls.name) {
608-
continue;
609-
}
610-
if !matches!(cls.kind, ClassLikeKind::Class | ClassLikeKind::Interface)
611-
{
612-
continue;
613-
}
614-
let cls_fqn = match file_namespace {
615-
Some(ns) => format!("{}\\{}", ns, cls.name),
616-
None => cls.name.to_string(),
617-
};
618-
if !matches_class_prefix(
619-
&cls.name,
620-
&cls_fqn,
621-
&prefix_lower,
622-
is_fqn_prefix,
623-
expanded_prefix_lower,
624-
) {
625-
continue;
626-
}
627-
if !seen_fqns.insert(cls_fqn.clone()) {
628-
continue;
629-
}
630-
candidates.push((
631-
cls.name.to_string(),
632-
cls_fqn,
633-
cls.deprecation_message.clone(),
634-
));
635-
}
507+
// Build a reverse lookup from FQN → use-import short name so we
508+
// can detect use-imported classes in O(1).
509+
let use_imported_fqns: HashSet<&String> = file_use_map.values().collect();
510+
511+
// Namespace prefix for "same namespace or sub-namespace" check.
512+
// For namespace `App\Models`, both `App\Models\User` and
513+
// `App\Models\Concerns\HasFactory` are considered same-or-sub.
514+
let ns_prefix = file_namespace.as_ref().map(|ns| format!("{}\\", ns));
515+
516+
// ── Helper: classify a FQN into a source tier and demotion ───
517+
//
518+
// Returns `Some((source_tier, demoted))` or `None` to exclude.
519+
// '0' = use-imported + confirmed Throwable
520+
// '1' = same/sub namespace + confirmed Throwable
521+
// '2' = everything else
522+
// `demoted` is true only in tier '2' when the short name doesn't
523+
// look like an exception class.
524+
//
525+
// Loaded classes that are confirmed NOT Throwable (class/interface
526+
// with a fully walkable chain that doesn't reach Throwable) are
527+
// excluded. Unloaded classes pass through with heuristic demotion.
528+
let classify = |fqn: &str, sn: &str| -> Option<(char, bool)> {
529+
// Check if loaded as a class or interface so we can verify
530+
// Throwable ancestry.
531+
let loaded_info = self.find_class_in_ast_map(fqn);
532+
let is_loaded_class_or_interface = loaded_info
533+
.as_ref()
534+
.is_some_and(|c| matches!(c.kind, ClassLikeKind::Class | ClassLikeKind::Interface));
535+
536+
if is_loaded_class_or_interface {
537+
if self.is_throwable_descendant(fqn, 0) {
538+
// Confirmed Throwable — assign tier by proximity.
539+
if use_imported_fqns.contains(&fqn.to_string()) {
540+
return Some(('0', false));
541+
}
542+
let in_same_or_sub_ns = match &ns_prefix {
543+
Some(pfx) => fqn.starts_with(pfx.as_str()),
544+
None => !fqn.contains('\\'),
545+
};
546+
if in_same_or_sub_ns {
547+
return Some(('1', false));
636548
}
549+
return Some(('2', false));
637550
}
551+
// Loaded as class/interface but NOT Throwable — exclude.
552+
return None;
638553
}
639-
// Phase 2: filter by Throwable ancestry without holding locks.
640-
for (name, fqn, deprecation_message) in candidates {
641-
if !self.is_throwable_descendant(&fqn, 0) {
642-
continue;
643-
}
644-
let (base_name, filter, _use_import) = class_edit_texts(
645-
&name,
646-
&fqn,
647-
is_fqn_prefix,
648-
has_leading_backslash,
649-
file_namespace,
650-
);
651-
let texts = ClassItemTexts {
652-
base_name,
653-
filter,
654-
use_import: None,
655-
};
656-
items.push(ctx.build_item(
657-
texts,
658-
&fqn,
659-
'1',
660-
false,
661-
None,
662-
deprecation_message.is_some(),
663-
));
664-
}
665-
}
666554

667-
// ── 1c. class_index (must be class/interface + Throwable) ───
555+
// Not loaded (or loaded as trait/enum which we skip) —
556+
// include with heuristic demotion.
557+
let demoted =
558+
!sn.ends_with("Exception") && !sn.ends_with("Error") && !sn.ends_with("Throwable");
559+
Some(('2', demoted))
560+
};
561+
562+
// ── Pass 1: fqn_uri_index (project + vendor classes) ────────
668563
{
669564
let idx = self.fqn_uri_index.read();
670565
for fqn in idx.keys() {
@@ -681,12 +576,9 @@ impl Backend {
681576
if !seen_fqns.insert(fqn.clone()) {
682577
continue;
683578
}
684-
if !self.is_class_or_interface_in_ast_map(fqn) {
685-
continue;
686-
}
687-
if !self.is_throwable_descendant(fqn, 0) {
579+
let Some((source_tier, demoted)) = classify(fqn, sn) else {
688580
continue;
689-
}
581+
};
690582
let (base_name, filter, use_import) = class_edit_texts(
691583
sn,
692584
fqn,
@@ -700,21 +592,14 @@ impl Backend {
700592
use_import,
701593
};
702594
ctx.apply_import_fixups(&mut texts.base_name, &mut texts.use_import, false);
703-
items.push(ctx.build_item(texts, fqn, '2', false, None, false));
595+
items.push(ctx.build_item(texts, fqn, source_tier, demoted, None, false));
704596
}
705597
}
706598

707-
// ── 2. Class index — names ending with "Exception" ───────────
708-
// ── 4. Class index — names NOT ending with "Exception" ───────
709-
// We collect both buckets in a single pass over the class index
710-
// and assign different sort_text prefixes so "Exception" entries
711-
// appear first.
599+
// ── Pass 2: stub_index (built-in PHP classes) ───────────────
712600
{
713-
let cmap = self.fqn_uri_index.read();
714-
for fqn in cmap.keys() {
715-
if loaded_fqns.contains(fqn) {
716-
continue;
717-
}
601+
let stub_idx = self.stub_index.read();
602+
for &fqn in stub_idx.keys() {
718603
let sn = short_name(fqn);
719604
if !matches_class_prefix(
720605
sn,
@@ -725,10 +610,12 @@ impl Backend {
725610
) {
726611
continue;
727612
}
728-
if !seen_fqns.insert(fqn.clone()) {
613+
if !seen_fqns.insert(fqn.to_string()) {
729614
continue;
730615
}
731-
let demoted = !sn.ends_with("Exception") && !sn.ends_with("Error");
616+
let Some((source_tier, demoted)) = classify(fqn, sn) else {
617+
continue;
618+
};
732619
let (base_name, filter, use_import) = class_edit_texts(
733620
sn,
734621
fqn,
@@ -742,48 +629,10 @@ impl Backend {
742629
use_import,
743630
};
744631
ctx.apply_import_fixups(&mut texts.base_name, &mut texts.use_import, false);
745-
items.push(ctx.build_item(texts, fqn, '2', demoted, None, false));
632+
items.push(ctx.build_item(texts, fqn, source_tier, demoted, None, false));
746633
}
747634
}
748635

749-
// ── 3. Stubs — names ending with "Exception" ────────────────
750-
// ── 5. Stubs — names NOT ending with "Exception" ────────────
751-
let stub_idx = self.stub_index.read();
752-
for &name in stub_idx.keys() {
753-
if loaded_fqns.contains(name) {
754-
continue;
755-
}
756-
let sn = short_name(name);
757-
if !matches_class_prefix(
758-
sn,
759-
name,
760-
&prefix_lower,
761-
is_fqn_prefix,
762-
expanded_prefix_lower,
763-
) {
764-
continue;
765-
}
766-
if !seen_fqns.insert(name.to_string()) {
767-
continue;
768-
}
769-
770-
let demoted = !sn.ends_with("Exception") && !sn.ends_with("Error");
771-
let (base_name, filter, use_import) = class_edit_texts(
772-
sn,
773-
name,
774-
is_fqn_prefix,
775-
has_leading_backslash,
776-
file_namespace,
777-
);
778-
let mut texts = ClassItemTexts {
779-
base_name,
780-
filter,
781-
use_import,
782-
};
783-
ctx.apply_import_fixups(&mut texts.base_name, &mut texts.use_import, false);
784-
items.push(ctx.build_item(texts, name, '2', demoted, None, false));
785-
}
786-
787636
let is_incomplete = items.len() > Self::MAX_CLASS_COMPLETIONS;
788637
if is_incomplete {
789638
items.sort_by(|a, b| a.sort_text.cmp(&b.sort_text));

0 commit comments

Comments
 (0)