@@ -29,7 +29,7 @@ use crate::util::{short_name, strip_fqn_prefix};
2929
3030use 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} ;
3434use crate :: completion:: builder:: analyze_use_block;
3535use 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