|
13 | 13 | //! The inline `/** @throws */` annotation is an escape hatch that lets |
14 | 14 | //! developers document exceptions from dependencies that don't have |
15 | 15 | //! `@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. |
21 | 16 |
|
22 | | -use std::collections::{HashMap, HashSet}; |
| 17 | +use std::collections::HashMap; |
23 | 18 |
|
24 | 19 | use tower_lsp::lsp_types::*; |
25 | 20 |
|
26 | 21 | 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 | + |
35 | 24 | use crate::completion::source::comment_position::position_to_byte_offset; |
36 | 25 | use crate::completion::source::throws_analysis; |
37 | 26 |
|
@@ -385,7 +374,7 @@ impl Backend { |
385 | 374 | /// when this returns `true`. |
386 | 375 | /// |
387 | 376 | /// 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 { |
389 | 378 | if depth > 20 { |
390 | 379 | return false; // prevent infinite loops |
391 | 380 | } |
@@ -419,228 +408,6 @@ impl Backend { |
419 | 408 | None => false, // class not loaded — can't confirm |
420 | 409 | } |
421 | 410 | } |
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 | | - } |
644 | 411 | } |
645 | 412 |
|
646 | 413 | #[cfg(test)] |
|
0 commit comments