Skip to content

Commit 7c86d78

Browse files
committed
Unify class name completion for throw new and catch contexts
1 parent 021e45e commit 7c86d78

6 files changed

Lines changed: 178 additions & 625 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **`throw new` completion no longer offers non-instantiable types.** Interfaces, abstract classes, traits, and enums are now filtered out, matching the behavior of `new` completion. The `throw new` path also now filters to Throwable descendants only.
13+
- **Unified class name completion architecture.** `throw new` and `catch()` completion now use the same `build_class_name_completions` pipeline as `new`, `extends`, `implements`, etc. `throw new` uses a `ThrowNew` context (instantiable + Throwable) and `catch()`/`@throws` uses a `Catch` context (class or interface + Throwable). This gives both contexts the same affinity scoring, FQN shortening via use-map, namespace segment drill-down, deprecation flags, and consistent filtering. The separate `build_catch_class_name_completions` function has been removed.
14+
- **Consolidated class completion passes.** The previous 5-pass architecture (use-map, same-namespace, fqn_uri_index, fqn_uri_index duplicate, stub_index) has been simplified to 2 passes (fqn_uri_index + stub_index) with an inline `classify` closure that determines tier (`'0'` use-imported, `'1'` same/sub-namespace, `'2'` everything else) per candidate. The redundant pass 4 (identical to pass 3) is eliminated, and tier assignment is now based on proximity checks rather than which data source produced the item.
15+
1016
## [0.8.0] - 2026-05-14
1117

1218
### Added
1319

1420
- **Blade template support.** Completion, hover, go-to-definition, diagnostics, semantic tokens, and inlay hints work inside `.blade.php` files. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/100.
1521
- **Blade keyword highlighting.** Blade directives, echo delimiters, PHP keywords, cast types, comments, and PHPDoc tags inside `.blade.php` files now receive semantic tokens for proper syntax coloring.
1622
- **Blade view directive navigation.** Go-to-definition works on view names inside Blade directives (`@include`, `@extends`, `@includeIf`, `@includeWhen`, `@includeUnless`, `@includeFirst`, `@component`, `@each`), jumping to the referenced template file.
17-
- **Replace FQCN with import.** A refactoring code action on any fully-qualified class name (`\Foo\Bar`) inserts a `use` statement and replaces the inline reference with the short name. Detects existing imports and short-name conflicts.
23+
- **Replace FQCN with import.** A refactoring code action on any fully-qualified class name (`\Foo\Bar`) inserts a `use` statement and replaces all occurrences of the same FQCN throughout the file with the short name. Detects existing imports and short-name conflicts. A separate "Replace all FQCNs with imports" action appears when the file contains multiple distinct FQCNs, replacing all of them at once (skipping those with import conflicts).
1824
- **Broader type narrowing.** `instanceof`, type-guard functions, `in_array()` strict mode, `assert()`, `@phpstan-assert-if-true`/`-if-false`, and compound `&&`/`||` conditions now narrow types in if/else branches, guard clauses, while-loop bodies, ternary expressions, and `match(true)` arms.
1925
- **Argument type mismatch diagnostics.** Flags function and method calls where an argument's resolved type is incompatible with the declared parameter type.
2026
- **Invalid class-like kind diagnostics.** Flags class-like names used in positions where their kind is guaranteed to fail at runtime: `new` on abstract classes, interfaces, traits, or enums; `extends` on a final class, interface, or trait; `implements` with a non-interface; trait `use` with a non-trait; `instanceof` with a trait; `catch` with a non-Throwable type; and traits in type-hint positions.
@@ -48,7 +54,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4854

4955
- **Find References performance and freshness.** Project-wide Find References now avoids more unnecessary file work while still returning references through aliased class and function imports, and it refreshes newly added workspace PHP files on later searches. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/116.
5056
- **Incremental text sync.** The server now uses incremental document sync, receiving only changed ranges from the editor instead of the full file content on every keystroke.
51-
- **Replace FQCN with import.** Now replaces all occurrences of the same FQCN throughout the file in one action, not just the one under the cursor. A new "Replace all FQCNs with imports" action appears when the file contains multiple distinct FQCNs, replacing all of them at once (skipping those with import conflicts).
5257
- **LSP responsiveness.** Hover, go-to-definition, signature help, code actions, rename, and other handlers now run on background threads. Slow requests no longer block other requests or cancellations.
5358
- **Faster analysis.** Analysis time cut significantly on large projects.
5459
- **Reduced redundant file parsing.** Concurrent threads resolving the same vendor class no longer parse the file in parallel; the second thread waits for the first to finish.

src/completion/context/catch_completion.rs

Lines changed: 4 additions & 237 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,14 @@
1313
//! The inline `/** @throws */` annotation is an escape hatch that lets
1414
//! developers document exceptions from dependencies that don't have
1515
//! `@throws` tags themselves.
16-
//!
17-
//! Also provides a Throwable-filtered class completion variant for catch
18-
//! clause fallback and `throw new` completion, which only suggests
19-
//! exception classes from already-parsed sources and includes everything
20-
//! else (class index, stubs) unfiltered.
2116
22-
use std::collections::{HashMap, HashSet};
17+
use std::collections::HashMap;
2318

2419
use tower_lsp::lsp_types::*;
2520

2621
use crate::Backend;
27-
use crate::types::*;
28-
use crate::util::{short_name, strip_fqn_prefix};
29-
30-
use super::class_completion::{
31-
ClassItemCtx, ClassItemTexts, build_affinity_table, class_edit_texts, expand_alias_prefix,
32-
matches_class_prefix,
33-
};
34-
use crate::completion::builder::analyze_use_block;
22+
use crate::util::short_name;
23+
3524
use crate::completion::source::comment_position::position_to_byte_offset;
3625
use crate::completion::source::throws_analysis;
3726

@@ -385,7 +374,7 @@ impl Backend {
385374
/// when this returns `true`.
386375
///
387376
/// This never triggers disk I/O; it only consults `uri_classes_index`.
388-
fn is_throwable_descendant(&self, class_name: &str, depth: u32) -> bool {
377+
pub(crate) fn is_throwable_descendant(&self, class_name: &str, depth: u32) -> bool {
389378
if depth > 20 {
390379
return false; // prevent infinite loops
391380
}
@@ -419,228 +408,6 @@ impl Backend {
419408
None => false, // class not loaded — can't confirm
420409
}
421410
}
422-
423-
/// Build completion items for class names suitable for `throw new`
424-
/// and `catch` contexts.
425-
///
426-
/// Every matching class from `fqn_uri_index` and `stub_index` is
427-
/// included exactly once. Sort priority is determined by:
428-
///
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).
434-
///
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.
438-
pub(crate) fn build_catch_class_name_completions(
439-
&self,
440-
ctx: &crate::types::FileContext,
441-
prefix: &str,
442-
content: &str,
443-
is_new: bool,
444-
position: Position,
445-
uri: &str,
446-
) -> (Vec<CompletionItem>, bool) {
447-
let file_use_map = &ctx.use_map;
448-
let file_namespace = &ctx.namespace;
449-
let has_leading_backslash = prefix.starts_with('\\');
450-
let normalized = strip_fqn_prefix(prefix);
451-
let prefix_lower = normalized.to_lowercase();
452-
let is_fqn_prefix = has_leading_backslash || normalized.contains('\\');
453-
454-
// When the prefix starts with an alias (e.g. `OA\Re` where
455-
// `use OpenApi\Attributes as OA`), expand it to the FQN form
456-
// so that `matches_class_prefix` can find classes under the
457-
// aliased namespace.
458-
let expanded = expand_alias_prefix(normalized, file_use_map);
459-
let expanded_lower = expanded.as_deref().map(|s| s.to_lowercase());
460-
let expanded_prefix_lower = expanded_lower.as_deref();
461-
462-
// When the user is typing a namespace-qualified reference,
463-
// provide an explicit replacement range so the editor replaces
464-
// the entire typed prefix (including namespace separators).
465-
let fqn_replace_range = if is_fqn_prefix {
466-
Some(Range {
467-
start: Position {
468-
line: position.line,
469-
character: position
470-
.character
471-
.saturating_sub(prefix.chars().count() as u32),
472-
},
473-
end: position,
474-
})
475-
} else {
476-
None
477-
};
478-
let mut seen_fqns: HashSet<String> = HashSet::new();
479-
let mut items: Vec<CompletionItem> = Vec::new();
480-
481-
// Extract the short-name portion of the typed prefix for match
482-
// quality classification.
483-
let quality_prefix = match normalized.rfind('\\') {
484-
Some(pos) => normalized[pos + 1..].to_string(),
485-
None => normalized.to_string(),
486-
};
487-
488-
// Build the affinity table from the file's use-map and namespace.
489-
let affinity_table = build_affinity_table(file_use_map, file_namespace);
490-
491-
let prefix_has_namespace = normalized.contains('\\');
492-
493-
let ctx = ClassItemCtx {
494-
is_fqn_prefix,
495-
is_new,
496-
is_attribute: false,
497-
fqn_replace_range,
498-
file_use_map,
499-
use_block: analyze_use_block(content),
500-
file_namespace,
501-
affinity_table,
502-
quality_prefix,
503-
prefix_has_namespace,
504-
uri,
505-
};
506-
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_uri_classes_index(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));
548-
}
549-
return Some(('2', false));
550-
}
551-
// Loaded as class/interface but NOT Throwable — exclude.
552-
return None;
553-
}
554-
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) ────────
563-
{
564-
let idx = self.fqn_uri_index.read();
565-
for fqn in idx.keys() {
566-
let sn = short_name(fqn);
567-
if !matches_class_prefix(
568-
sn,
569-
fqn,
570-
&prefix_lower,
571-
is_fqn_prefix,
572-
expanded_prefix_lower,
573-
) {
574-
continue;
575-
}
576-
if !seen_fqns.insert(fqn.clone()) {
577-
continue;
578-
}
579-
let Some((source_tier, demoted)) = classify(fqn, sn) else {
580-
continue;
581-
};
582-
let (base_name, filter, use_import) = class_edit_texts(
583-
sn,
584-
fqn,
585-
is_fqn_prefix,
586-
has_leading_backslash,
587-
file_namespace,
588-
);
589-
let mut texts = ClassItemTexts {
590-
base_name,
591-
filter,
592-
use_import,
593-
};
594-
ctx.apply_import_fixups(&mut texts.base_name, &mut texts.use_import, false);
595-
items.push(ctx.build_item(texts, fqn, source_tier, demoted, None, false));
596-
}
597-
}
598-
599-
// ── Pass 2: stub_index (built-in PHP classes) ───────────────
600-
{
601-
let stub_idx = self.stub_index.read();
602-
for &fqn in stub_idx.keys() {
603-
let sn = short_name(fqn);
604-
if !matches_class_prefix(
605-
sn,
606-
fqn,
607-
&prefix_lower,
608-
is_fqn_prefix,
609-
expanded_prefix_lower,
610-
) {
611-
continue;
612-
}
613-
if !seen_fqns.insert(fqn.to_string()) {
614-
continue;
615-
}
616-
let Some((source_tier, demoted)) = classify(fqn, sn) else {
617-
continue;
618-
};
619-
let (base_name, filter, use_import) = class_edit_texts(
620-
sn,
621-
fqn,
622-
is_fqn_prefix,
623-
has_leading_backslash,
624-
file_namespace,
625-
);
626-
let mut texts = ClassItemTexts {
627-
base_name,
628-
filter,
629-
use_import,
630-
};
631-
ctx.apply_import_fixups(&mut texts.base_name, &mut texts.use_import, false);
632-
items.push(ctx.build_item(texts, fqn, source_tier, demoted, None, false));
633-
}
634-
}
635-
636-
let is_incomplete = items.len() > Self::MAX_CLASS_COMPLETIONS;
637-
if is_incomplete {
638-
items.sort_by(|a, b| a.sort_text.cmp(&b.sort_text));
639-
items.truncate(Self::MAX_CLASS_COMPLETIONS);
640-
}
641-
642-
(items, is_incomplete)
643-
}
644411
}
645412

646413
#[cfg(test)]

0 commit comments

Comments
 (0)