@@ -306,7 +306,7 @@ impl Backend {
306306 ( items, is_incomplete)
307307 }
308308
309- // ─── Catch clause fallback completion ──────────── ───────────────
309+ // ─── Catch clause / throw-new fallback completion ───────────────
310310
311311 /// Check whether a class is a confirmed `\Throwable` descendant using
312312 /// only already-loaded data from the `ast_map`.
@@ -374,6 +374,43 @@ impl Backend {
374374 }
375375 }
376376
377+ /// Check whether the class identified by `class_name` is a concrete,
378+ /// non-abstract `class` (i.e. `ClassLikeKind::Class` and not
379+ /// `is_abstract`) in the `ast_map`.
380+ ///
381+ /// Returns `false` for interfaces, traits, enums, abstract classes,
382+ /// and classes that are not currently loaded. This never triggers
383+ /// disk I/O.
384+ fn is_concrete_class_in_ast_map ( & self , class_name : & str ) -> bool {
385+ let normalized = class_name. strip_prefix ( '\\' ) . unwrap_or ( class_name) ;
386+ let last_segment = normalized. rsplit ( '\\' ) . next ( ) . unwrap_or ( normalized) ;
387+ let expected_ns: Option < & str > = if normalized. contains ( '\\' ) {
388+ Some ( & normalized[ ..normalized. len ( ) - last_segment. len ( ) - 1 ] )
389+ } else {
390+ None
391+ } ;
392+
393+ let Some ( map) = self . ast_map . lock ( ) . ok ( ) else {
394+ return false ;
395+ } ;
396+ let nmap = self . namespace_map . lock ( ) . ok ( ) ;
397+ for ( uri, classes) in map. iter ( ) {
398+ if let Some ( c) = classes. iter ( ) . find ( |c| c. name == last_segment) {
399+ if let Some ( exp_ns) = expected_ns {
400+ let file_ns = nmap
401+ . as_ref ( )
402+ . and_then ( |nm| nm. get ( uri) )
403+ . and_then ( |opt| opt. as_deref ( ) ) ;
404+ if file_ns != Some ( exp_ns) {
405+ continue ;
406+ }
407+ }
408+ return c. kind == ClassLikeKind :: Class && !c. is_abstract ;
409+ }
410+ }
411+ false
412+ }
413+
377414 /// Collect the FQN of every class that is currently loaded in the
378415 /// `ast_map`. Used by `build_catch_class_name_completions` so that
379416 /// classmap / stub sources can skip classes we already evaluated.
@@ -402,16 +439,21 @@ impl Backend {
402439
403440 /// Build completion items for class names, filtered for Throwable
404441 /// descendants. Used as the catch clause fallback when no specific
405- /// `@throws` types were discovered in the try block.
442+ /// `@throws` types were discovered in the try block, and for
443+ /// `throw new` completion.
406444 ///
407445 /// The logic follows this priority:
408446 ///
409- /// 1. **Loaded classes** (use-imports, same-namespace, class_index):
410- /// only classes whose parent chain is fully walkable to
411- /// `\Throwable` / `\Exception` / `\Error` (`must_extend`).
412- /// 2. **Classmap / stubs** (not yet parsed): included *unless* the
413- /// FQN is already in the loaded set — this prevents non-exception
414- /// loaded classes from sneaking back in through these sources.
447+ /// 1. **Loaded concrete classes** (use-imports, same-namespace,
448+ /// class_index): only classes (not interfaces/traits/enums) whose
449+ /// parent chain is fully walkable to `\Throwable` / `\Exception`
450+ /// / `\Error`.
451+ /// 2. **Classmap** entries (not yet parsed) whose short name ends
452+ /// with `Exception` — filtered to exclude already-loaded FQNs.
453+ /// 3. **Stub** entries whose short name ends with `Exception` —
454+ /// filtered to exclude already-loaded FQNs.
455+ /// 4. **Classmap** entries that do *not* end with `Exception`.
456+ /// 5. **Stub** entries that do *not* end with `Exception`.
415457 pub ( crate ) fn build_catch_class_name_completions (
416458 & self ,
417459 file_use_map : & HashMap < String , String > ,
@@ -429,14 +471,18 @@ impl Backend {
429471 // classmap / stub sources can exclude already-evaluated classes.
430472 let loaded_fqns = self . collect_loaded_fqns ( ) ;
431473
432- // ── 1 . Use-imported classes (must_extend Throwable) ──────── ─
474+ // ── 1a . Use-imported classes (must be concrete + Throwable) ─
433475 for ( short_name, fqn) in file_use_map {
434476 if !short_name. to_lowercase ( ) . contains ( & prefix_lower) {
435477 continue ;
436478 }
437479 if !seen_fqns. insert ( fqn. clone ( ) ) {
438480 continue ;
439481 }
482+ // Only concrete classes (not interfaces/traits/enums)
483+ if !self . is_concrete_class_in_ast_map ( fqn) {
484+ continue ;
485+ }
440486 // Strict check: only include if confirmed Throwable descendant
441487 if !self . is_throwable_descendant ( fqn, 0 ) {
442488 continue ;
@@ -452,7 +498,7 @@ impl Backend {
452498 } ) ;
453499 }
454500
455- // ── 2 . Same-namespace classes (must_extend Throwable) ───────
501+ // ── 1b . Same-namespace classes (must be concrete + Throwable)
456502 // Collect candidates while holding the lock, then drop the lock
457503 // before calling `is_throwable_descendant` (which re-locks
458504 // `ast_map` internally — Rust's Mutex is not re-entrant).
@@ -472,12 +518,15 @@ impl Backend {
472518 drop ( nmap) ;
473519
474520 // Phase 1: collect candidate (name, fqn, is_deprecated)
475- // tuples under the ast_map lock.
521+ // tuples under the ast_map lock — only concrete classes .
476522 let mut candidates: Vec < ( String , String , bool ) > = Vec :: new ( ) ;
477523 if let Ok ( amap) = self . ast_map . lock ( ) {
478524 for uri in & same_ns_uris {
479525 if let Some ( classes) = amap. get ( uri) {
480526 for cls in classes {
527+ if cls. kind != ClassLikeKind :: Class || cls. is_abstract {
528+ continue ;
529+ }
481530 if !cls. name . to_lowercase ( ) . contains ( & prefix_lower) {
482531 continue ;
483532 }
@@ -508,7 +557,7 @@ impl Backend {
508557 }
509558 }
510559
511- // ── 3 . class_index (must_extend Throwable) ──────── ──────────
560+ // ── 1c . class_index (must be concrete + Throwable) ──────────
512561 if let Ok ( idx) = self . class_index . lock ( ) {
513562 for fqn in idx. keys ( ) {
514563 let short_name = fqn. rsplit ( '\\' ) . next ( ) . unwrap_or ( fqn) ;
@@ -518,6 +567,9 @@ impl Backend {
518567 if !seen_fqns. insert ( fqn. clone ( ) ) {
519568 continue ;
520569 }
570+ if !self . is_concrete_class_in_ast_map ( fqn) {
571+ continue ;
572+ }
521573 if !self . is_throwable_descendant ( fqn, 0 ) {
522574 continue ;
523575 }
@@ -534,10 +586,13 @@ impl Backend {
534586 }
535587 }
536588
537- // ── 4. Composer classmap (filter out already-loaded) ────────
589+ // ── 2. Classmap — names ending with "Exception" ─────────────
590+ // ── 4. Classmap — names NOT ending with "Exception" ─────────
591+ // We collect both buckets in a single pass over the classmap and
592+ // assign different sort_text prefixes so "Exception" entries
593+ // appear first.
538594 if let Ok ( cmap) = self . classmap . lock ( ) {
539595 for fqn in cmap. keys ( ) {
540- // Skip classes we already evaluated in the loaded sources
541596 if loaded_fqns. contains ( fqn) {
542597 continue ;
543598 }
@@ -548,22 +603,27 @@ impl Backend {
548603 if !seen_fqns. insert ( fqn. clone ( ) ) {
549604 continue ;
550605 }
606+ let prefix_num = if short_name. ends_with ( "Exception" ) {
607+ "3"
608+ } else {
609+ "5"
610+ } ;
551611 items. push ( CompletionItem {
552612 label : short_name. to_string ( ) ,
553613 kind : Some ( CompletionItemKind :: CLASS ) ,
554614 detail : Some ( fqn. clone ( ) ) ,
555615 insert_text : Some ( short_name. to_string ( ) ) ,
556616 filter_text : Some ( short_name. to_string ( ) ) ,
557- sort_text : Some ( format ! ( "3_{}" , short_name. to_lowercase( ) ) ) ,
617+ sort_text : Some ( format ! ( "{}_{}" , prefix_num , short_name. to_lowercase( ) ) ) ,
558618 additional_text_edits : build_use_edit ( fqn, use_insert_pos, file_namespace) ,
559619 ..CompletionItem :: default ( )
560620 } ) ;
561621 }
562622 }
563623
564- // ── 5. Built-in PHP classes from stubs (filter out loaded) ──
624+ // ── 3. Stubs — names ending with "Exception" ────────────────
625+ // ── 5. Stubs — names NOT ending with "Exception" ────────────
565626 for & name in self . stub_index . keys ( ) {
566- // Skip classes we already evaluated in the loaded sources
567627 if loaded_fqns. contains ( name) {
568628 continue ;
569629 }
@@ -574,13 +634,18 @@ impl Backend {
574634 if !seen_fqns. insert ( name. to_string ( ) ) {
575635 continue ;
576636 }
637+ let prefix_num = if short_name. ends_with ( "Exception" ) {
638+ "4"
639+ } else {
640+ "6"
641+ } ;
577642 items. push ( CompletionItem {
578643 label : short_name. to_string ( ) ,
579644 kind : Some ( CompletionItemKind :: CLASS ) ,
580645 detail : Some ( name. to_string ( ) ) ,
581646 insert_text : Some ( short_name. to_string ( ) ) ,
582647 filter_text : Some ( short_name. to_string ( ) ) ,
583- sort_text : Some ( format ! ( "4_{}" , short_name. to_lowercase( ) ) ) ,
648+ sort_text : Some ( format ! ( "{}_{}" , prefix_num , short_name. to_lowercase( ) ) ) ,
584649 additional_text_edits : build_use_edit ( name, use_insert_pos, file_namespace) ,
585650 ..CompletionItem :: default ( )
586651 } ) ;
0 commit comments