Skip to content

Commit 5cb29a7

Browse files
committed
Fliter completion of identifiers
1 parent 60a3f65 commit 5cb29a7

3 files changed

Lines changed: 142 additions & 67 deletions

File tree

src/completion/builder.rs

Lines changed: 64 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -334,22 +334,34 @@ impl Backend {
334334
/// Sources (in priority order):
335335
/// 1. Classes imported via `use` statements in the current file
336336
/// 2. Classes in the same namespace (from the ast_map)
337-
/// 3. Built-in PHP classes from embedded stubs
337+
/// 3. Classes from the class_index (discovered during parsing)
338338
/// 4. Classes from the Composer classmap (`autoload_classmap.php`)
339-
/// 5. Classes from the class_index (discovered during parsing)
339+
/// 5. Built-in PHP classes from embedded stubs
340340
///
341341
/// Each item uses the short class name as `label` and the
342342
/// fully-qualified name as `detail`. Items are deduplicated by FQN.
343+
///
344+
/// Returns `(items, is_incomplete)`. When the total number of
345+
/// matching classes exceeds [`MAX_CLASS_COMPLETIONS`], the result is
346+
/// truncated and `is_incomplete` is `true`, signalling the client to
347+
/// re-request as the user types more characters.
348+
const MAX_CLASS_COMPLETIONS: usize = 100;
349+
343350
pub(crate) fn build_class_name_completions(
344351
&self,
345352
file_use_map: &HashMap<String, String>,
346353
file_namespace: &Option<String>,
347-
) -> Vec<CompletionItem> {
354+
prefix: &str,
355+
) -> (Vec<CompletionItem>, bool) {
356+
let prefix_lower = prefix.to_lowercase();
348357
let mut seen_fqns: HashSet<String> = HashSet::new();
349358
let mut items: Vec<CompletionItem> = Vec::new();
350359

351360
// ── 1. Use-imported classes (highest priority) ──────────────
352361
for (short_name, fqn) in file_use_map {
362+
if !short_name.to_lowercase().contains(&prefix_lower) {
363+
continue;
364+
}
353365
if !seen_fqns.insert(fqn.clone()) {
354366
continue;
355367
}
@@ -385,6 +397,9 @@ impl Backend {
385397
for uri in &same_ns_uris {
386398
if let Some(classes) = amap.get(uri) {
387399
for cls in classes {
400+
if !cls.name.to_lowercase().contains(&prefix_lower) {
401+
continue;
402+
}
388403
let fqn = format!("{}\\{}", ns, cls.name);
389404
if !seen_fqns.insert(fqn.clone()) {
390405
continue;
@@ -404,49 +419,38 @@ impl Backend {
404419
}
405420
}
406421

407-
// ── 3. Built-in PHP classes from stubs ──────────────────────
408-
for &name in self.stub_index.keys() {
409-
if !seen_fqns.insert(name.to_string()) {
410-
continue;
411-
}
412-
let short_name = name.rsplit('\\').next().unwrap_or(name);
413-
items.push(CompletionItem {
414-
label: short_name.to_string(),
415-
kind: Some(CompletionItemKind::CLASS),
416-
detail: Some(name.to_string()),
417-
insert_text: Some(short_name.to_string()),
418-
filter_text: Some(short_name.to_string()),
419-
sort_text: Some(format!("2_{}", short_name.to_lowercase())),
420-
..CompletionItem::default()
421-
});
422-
}
423-
424-
// ── 4. Composer classmap ────────────────────────────────────
425-
if let Ok(cmap) = self.classmap.lock() {
426-
for fqn in cmap.keys() {
422+
// ── 3. class_index (discovered / interacted-with classes) ───
423+
if let Ok(idx) = self.class_index.lock() {
424+
for fqn in idx.keys() {
425+
let short_name = fqn.rsplit('\\').next().unwrap_or(fqn);
426+
if !short_name.to_lowercase().contains(&prefix_lower) {
427+
continue;
428+
}
427429
if !seen_fqns.insert(fqn.clone()) {
428430
continue;
429431
}
430-
let short_name = fqn.rsplit('\\').next().unwrap_or(fqn);
431432
items.push(CompletionItem {
432433
label: short_name.to_string(),
433434
kind: Some(CompletionItemKind::CLASS),
434435
detail: Some(fqn.clone()),
435436
insert_text: Some(short_name.to_string()),
436437
filter_text: Some(short_name.to_string()),
437-
sort_text: Some(format!("3_{}", short_name.to_lowercase())),
438+
sort_text: Some(format!("2_{}", short_name.to_lowercase())),
438439
..CompletionItem::default()
439440
});
440441
}
441442
}
442443

443-
// ── 5. class_index (discovered classes) ─────────────────────
444-
if let Ok(idx) = self.class_index.lock() {
445-
for fqn in idx.keys() {
444+
// ── 4. Composer classmap (all autoloaded classes) ───────────
445+
if let Ok(cmap) = self.classmap.lock() {
446+
for fqn in cmap.keys() {
447+
let short_name = fqn.rsplit('\\').next().unwrap_or(fqn);
448+
if !short_name.to_lowercase().contains(&prefix_lower) {
449+
continue;
450+
}
446451
if !seen_fqns.insert(fqn.clone()) {
447452
continue;
448453
}
449-
let short_name = fqn.rsplit('\\').next().unwrap_or(fqn);
450454
items.push(CompletionItem {
451455
label: short_name.to_string(),
452456
kind: Some(CompletionItemKind::CLASS),
@@ -459,6 +463,36 @@ impl Backend {
459463
}
460464
}
461465

462-
items
466+
// ── 5. Built-in PHP classes from stubs (lowest priority) ────
467+
for &name in self.stub_index.keys() {
468+
let short_name = name.rsplit('\\').next().unwrap_or(name);
469+
if !short_name.to_lowercase().contains(&prefix_lower) {
470+
continue;
471+
}
472+
if !seen_fqns.insert(name.to_string()) {
473+
continue;
474+
}
475+
items.push(CompletionItem {
476+
label: short_name.to_string(),
477+
kind: Some(CompletionItemKind::CLASS),
478+
detail: Some(name.to_string()),
479+
insert_text: Some(short_name.to_string()),
480+
filter_text: Some(short_name.to_string()),
481+
sort_text: Some(format!("4_{}", short_name.to_lowercase())),
482+
..CompletionItem::default()
483+
});
484+
}
485+
486+
// Cap the result set so the client isn't overwhelmed.
487+
// Sort by sort_text first so that higher-priority items
488+
// (use-imports, same-namespace, user project classes) survive
489+
// the truncation ahead of lower-priority SPL stubs.
490+
let is_incomplete = items.len() > Self::MAX_CLASS_COMPLETIONS;
491+
if is_incomplete {
492+
items.sort_by(|a, b| a.sort_text.cmp(&b.sort_text));
493+
items.truncate(Self::MAX_CLASS_COMPLETIONS);
494+
}
495+
496+
(items, is_incomplete)
463497
}
464498
}

src/server.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -456,10 +456,14 @@ impl LanguageServer for Backend {
456456
// user is typing a class name and offer completions from all
457457
// known sources (use-imports, same namespace, stubs, classmap,
458458
// class_index).
459-
if Self::extract_partial_class_name(&content, position).is_some() {
460-
let class_items = self.build_class_name_completions(&file_use_map, &file_namespace);
459+
if let Some(partial) = Self::extract_partial_class_name(&content, position) {
460+
let (class_items, is_incomplete) =
461+
self.build_class_name_completions(&file_use_map, &file_namespace, &partial);
461462
if !class_items.is_empty() {
462-
return Ok(Some(CompletionResponse::Array(class_items)));
463+
return Ok(Some(CompletionResponse::List(CompletionList {
464+
is_incomplete,
465+
items: class_items,
466+
})));
463467
}
464468
}
465469
}

tests/completion_class_names.rs

Lines changed: 71 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -175,27 +175,33 @@ async fn test_class_name_completion_includes_stubs() {
175175
let backend = create_test_backend_with_stubs();
176176

177177
let uri = Url::parse("file:///test.php").unwrap();
178-
let text = concat!("<?php\n", "new Date\n",);
179178

180-
let items = complete_at(&backend, &uri, text, 1, 8).await;
181-
let classes = class_items(&items);
179+
// Check UnitEnum is found when typing "Unit"
180+
let text_unit = concat!("<?php\n", "new Unit\n",);
181+
let items_unit = complete_at(&backend, &uri, text_unit, 1, 8).await;
182+
let classes_unit = class_items(&items_unit);
183+
let labels_unit: Vec<&str> = classes_unit.iter().map(|i| i.label.as_str()).collect();
182184

183185
assert!(
184-
!classes.is_empty(),
186+
!classes_unit.is_empty(),
185187
"Should return class name completions when typing a class name"
186188
);
187-
188-
// The stub backend has UnitEnum and BackedEnum
189-
let class_labels: Vec<&str> = classes.iter().map(|i| i.label.as_str()).collect();
190189
assert!(
191-
class_labels.contains(&"UnitEnum"),
190+
labels_unit.contains(&"UnitEnum"),
192191
"Should include stub class 'UnitEnum', got: {:?}",
193-
class_labels
192+
labels_unit
194193
);
194+
195+
// Check BackedEnum is found when typing "Backed"
196+
let text_backed = concat!("<?php\n", "new Backed\n",);
197+
let items_backed = complete_at(&backend, &uri, text_backed, 1, 10).await;
198+
let classes_backed = class_items(&items_backed);
199+
let labels_backed: Vec<&str> = classes_backed.iter().map(|i| i.label.as_str()).collect();
200+
195201
assert!(
196-
class_labels.contains(&"BackedEnum"),
202+
labels_backed.contains(&"BackedEnum"),
197203
"Should include stub class 'BackedEnum', got: {:?}",
198-
class_labels
204+
labels_backed
199205
);
200206
}
201207

@@ -424,8 +430,9 @@ async fn test_class_name_completion_from_classmap() {
424430
}
425431

426432
let uri = Url::parse("file:///app.php").unwrap();
427-
let text = concat!("<?php\n", "new Coll\n",);
428433

434+
// Check Collection matches prefix "Coll"
435+
let text = concat!("<?php\n", "new Coll\n",);
429436
let items = complete_at(&backend, &uri, text, 1, 8).await;
430437
let classes = class_items(&items);
431438
let class_labels: Vec<&str> = classes.iter().map(|i| i.label.as_str()).collect();
@@ -435,15 +442,27 @@ async fn test_class_name_completion_from_classmap() {
435442
"Should include classmap class 'Collection', got: {:?}",
436443
class_labels
437444
);
445+
446+
// Check Model matches prefix "Mo"
447+
let text_mo = concat!("<?php\n", "new Mo\n",);
448+
let items_mo = complete_at(&backend, &uri, text_mo, 1, 6).await;
449+
let classes_mo = class_items(&items_mo);
450+
let labels_mo: Vec<&str> = classes_mo.iter().map(|i| i.label.as_str()).collect();
438451
assert!(
439-
class_labels.contains(&"Model"),
452+
labels_mo.contains(&"Model"),
440453
"Should include classmap class 'Model', got: {:?}",
441-
class_labels
454+
labels_mo
442455
);
456+
457+
// Check Carbon matches prefix "Car"
458+
let text_car = concat!("<?php\n", "new Car\n",);
459+
let items_car = complete_at(&backend, &uri, text_car, 1, 7).await;
460+
let classes_car = class_items(&items_car);
461+
let labels_car: Vec<&str> = classes_car.iter().map(|i| i.label.as_str()).collect();
443462
assert!(
444-
class_labels.contains(&"Carbon"),
463+
labels_car.contains(&"Carbon"),
445464
"Should include classmap class 'Carbon', got: {:?}",
446-
class_labels
465+
labels_car
447466
);
448467

449468
// Check that detail shows the FQN
@@ -480,8 +499,9 @@ async fn test_class_name_completion_from_class_index() {
480499
}
481500

482501
let uri = Url::parse("file:///test.php").unwrap();
483-
let text = concat!("<?php\n", "new Us\n",);
484502

503+
// Check User matches prefix "Us"
504+
let text = concat!("<?php\n", "new Us\n",);
485505
let items = complete_at(&backend, &uri, text, 1, 6).await;
486506
let classes = class_items(&items);
487507
let class_labels: Vec<&str> = classes.iter().map(|i| i.label.as_str()).collect();
@@ -491,10 +511,17 @@ async fn test_class_name_completion_from_class_index() {
491511
"Should include class_index class 'User', got: {:?}",
492512
class_labels
493513
);
514+
515+
// Check Order matches prefix "Or"
516+
let text_or = concat!("<?php\n", "new Or\n",);
517+
let items_or = complete_at(&backend, &uri, text_or, 1, 6).await;
518+
let classes_or = class_items(&items_or);
519+
let labels_or: Vec<&str> = classes_or.iter().map(|i| i.label.as_str()).collect();
520+
494521
assert!(
495-
class_labels.contains(&"Order"),
522+
labels_or.contains(&"Order"),
496523
"Should include class_index class 'Order', got: {:?}",
497-
class_labels
524+
labels_or
498525
);
499526
}
500527

@@ -724,33 +751,43 @@ async fn test_class_name_completion_combines_all_sources() {
724751
);
725752
}
726753

727-
// Open a file with a use statement
754+
// Open a file with a use statement — use a prefix that matches
755+
// classes from all three sources. All test class names end with
756+
// "Class", so the prefix "Cl" only matches "ClassmapClass".
757+
// Instead we use separate checks per source.
728758
let uri = Url::parse("file:///test.php").unwrap();
729-
let text = concat!("<?php\n", "use App\\IndexedClass;\n", "new Cl\n",);
730-
731-
let items = complete_at(&backend, &uri, text, 2, 6).await;
732-
let classes = class_items(&items);
733-
let class_labels: Vec<&str> = classes.iter().map(|i| i.label.as_str()).collect();
734759

735-
// Should include from stubs
760+
// Check stubs: "Stub" matches "StubClass"
761+
let text_stub = concat!("<?php\n", "use App\\IndexedClass;\n", "new Stub\n",);
762+
let items_stub = complete_at(&backend, &uri, text_stub, 2, 8).await;
763+
let classes_stub = class_items(&items_stub);
764+
let labels_stub: Vec<&str> = classes_stub.iter().map(|i| i.label.as_str()).collect();
736765
assert!(
737-
class_labels.contains(&"StubClass"),
766+
labels_stub.contains(&"StubClass"),
738767
"Should include stub class, got: {:?}",
739-
class_labels
768+
labels_stub
740769
);
741770

742-
// Should include from classmap
771+
// Check classmap: "Classmap" matches "ClassmapClass"
772+
let text_cm = concat!("<?php\n", "use App\\IndexedClass;\n", "new Classmap\n",);
773+
let items_cm = complete_at(&backend, &uri, text_cm, 2, 12).await;
774+
let classes_cm = class_items(&items_cm);
775+
let labels_cm: Vec<&str> = classes_cm.iter().map(|i| i.label.as_str()).collect();
743776
assert!(
744-
class_labels.contains(&"ClassmapClass"),
777+
labels_cm.contains(&"ClassmapClass"),
745778
"Should include classmap class, got: {:?}",
746-
class_labels
779+
labels_cm
747780
);
748781

749-
// Should include from use-import (which is also in class_index)
782+
// Check use-import / class_index: "Indexed" matches "IndexedClass"
783+
let text_idx = concat!("<?php\n", "use App\\IndexedClass;\n", "new Indexed\n",);
784+
let items_idx = complete_at(&backend, &uri, text_idx, 2, 11).await;
785+
let classes_idx = class_items(&items_idx);
786+
let labels_idx: Vec<&str> = classes_idx.iter().map(|i| i.label.as_str()).collect();
750787
assert!(
751-
class_labels.contains(&"IndexedClass"),
788+
labels_idx.contains(&"IndexedClass"),
752789
"Should include use-imported / class_index class, got: {:?}",
753-
class_labels
790+
labels_idx
754791
);
755792
}
756793

0 commit comments

Comments
 (0)