Skip to content

Commit bc0fc59

Browse files
committed
Improve exception suggestions
1 parent 687e5e7 commit bc0fc59

3 files changed

Lines changed: 123 additions & 18 deletions

File tree

src/completion/class_completion.rs

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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
});

src/parser/classes.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
/// Class, interface, trait, and enum extraction.
22
///
3+
/// Each class-like declaration is tagged with a [`ClassLikeKind`] so that
4+
/// downstream consumers (e.g. `throw new` completion) can distinguish
5+
/// concrete classes from interfaces, traits, and enums.
6+
///
37
/// This module handles extracting `ClassInfo` from the PHP AST for all
48
/// class-like declarations: `class`, `interface`, `trait`, and `enum`.
59
/// It also extracts class-like members (methods, properties, constants,
@@ -86,8 +90,10 @@ impl Backend {
8690
let end_offset = class.right_brace.end.offset;
8791

8892
let is_final = class.modifiers.contains_final();
93+
let is_abstract = class.modifiers.contains_abstract();
8994

9095
classes.push(ClassInfo {
96+
kind: ClassLikeKind::Class,
9197
name: class_name,
9298
methods,
9399
properties,
@@ -98,6 +104,7 @@ impl Backend {
98104
used_traits,
99105
mixins,
100106
is_final,
107+
is_abstract,
101108
is_deprecated: class_deprecated,
102109
template_params,
103110
extends_generics,
@@ -167,6 +174,7 @@ impl Backend {
167174
let end_offset = iface.right_brace.end.offset;
168175

169176
classes.push(ClassInfo {
177+
kind: ClassLikeKind::Interface,
170178
name: iface_name,
171179
methods,
172180
properties,
@@ -177,6 +185,7 @@ impl Backend {
177185
used_traits,
178186
mixins,
179187
is_final: false,
188+
is_abstract: false,
180189
is_deprecated: iface_deprecated,
181190
template_params,
182191
extends_generics,
@@ -234,6 +243,7 @@ impl Backend {
234243
let end_offset = trait_def.right_brace.end.offset;
235244

236245
classes.push(ClassInfo {
246+
kind: ClassLikeKind::Trait,
237247
name: trait_name,
238248
methods,
239249
properties,
@@ -244,6 +254,7 @@ impl Backend {
244254
used_traits,
245255
mixins,
246256
is_final: false,
257+
is_abstract: false,
247258
is_deprecated: trait_deprecated,
248259
template_params,
249260
extends_generics: vec![],
@@ -313,6 +324,7 @@ impl Backend {
313324

314325
// Enums are implicitly final — they cannot be extended.
315326
classes.push(ClassInfo {
327+
kind: ClassLikeKind::Enum,
316328
name: enum_name,
317329
methods,
318330
properties,
@@ -323,6 +335,7 @@ impl Backend {
323335
used_traits,
324336
mixins,
325337
is_final: true,
338+
is_abstract: false,
326339
is_deprecated: enum_deprecated,
327340
template_params: vec![],
328341
extends_generics: vec![],

src/types.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,10 +273,31 @@ pub enum ParamCondition {
273273
IsType(String),
274274
}
275275

276+
/// The syntactic kind of a class-like declaration.
277+
///
278+
/// PHP has four class-like constructs that share the same `ClassInfo`
279+
/// representation. This enum lets callers distinguish them when the
280+
/// difference matters (e.g. `throw new` completion should only offer
281+
/// concrete classes, not interfaces or traits).
282+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
283+
pub enum ClassLikeKind {
284+
/// A regular `class` declaration (the default).
285+
#[default]
286+
Class,
287+
/// An `interface` declaration.
288+
Interface,
289+
/// A `trait` declaration.
290+
Trait,
291+
/// An `enum` declaration.
292+
Enum,
293+
}
294+
276295
/// Stores extracted class information from a parsed PHP file.
277296
/// All data is owned so we don't depend on the parser's arena lifetime.
278297
#[derive(Debug, Clone, Default)]
279298
pub struct ClassInfo {
299+
/// The syntactic kind of this class-like declaration.
300+
pub kind: ClassLikeKind,
280301
/// The name of the class (e.g. "User").
281302
pub name: String,
282303
/// The methods defined directly in this class.
@@ -305,6 +326,12 @@ pub struct ClassInfo {
305326
/// Final classes cannot be extended, so `static::` is equivalent to
306327
/// `self::` and need not be offered as a separate completion subject.
307328
pub is_final: bool,
329+
/// Whether the class is declared `abstract`.
330+
///
331+
/// Abstract classes cannot be instantiated directly, so they should
332+
/// be excluded from contexts like `throw new` or `new` completion
333+
/// where only concrete classes are valid.
334+
pub is_abstract: bool,
308335
/// Whether this class is marked `@deprecated` in its PHPDoc.
309336
pub is_deprecated: bool,
310337
/// Template parameter names declared via `@template` / `@template-covariant`

0 commit comments

Comments
 (0)