use std::collections::HashMap;
use crate::hover::shorten_php_type;
use std::sync::Arc;
use tower_lsp::lsp_types::*;
use super::resolve::CompletionItemData;
use crate::types::Visibility;
use crate::types::*;
fn display_class_name(name: &str) -> &str {
if name.starts_with("__anonymous@") {
"anonymous class"
} else {
name
}
}
pub(crate) fn build_callable_snippet(name: &str, params: &[ParameterInfo]) -> String {
let required: Vec<&ParameterInfo> = params.iter().filter(|p| p.is_required).collect();
if required.is_empty() {
format!("{name}()$0")
} else {
let placeholders: Vec<String> = required
.iter()
.enumerate()
.map(|(i, p)| {
let escaped_name = p.name.replace('$', "\\$");
format!("${{{}:{}}}", i + 1, escaped_name)
})
.collect();
format!("{name}({})$0", placeholders.join(", "))
}
}
pub(crate) fn build_attribute_snippet(name: &str, params: &[ParameterInfo]) -> String {
let required: Vec<&ParameterInfo> = params.iter().filter(|p| p.is_required).collect();
if required.is_empty() {
name.to_string()
} else {
let placeholders: Vec<String> = required
.iter()
.enumerate()
.map(|(i, p)| {
let arg_name = p.name.strip_prefix('$').unwrap_or(&p.name);
let (prefix, placeholder, suffix) = attribute_placeholder(p);
format!("{arg_name}: {prefix}${{{}:{}}}{suffix}", i + 1, placeholder)
})
.collect();
format!("{name}({})$0", placeholders.join(", "))
}
}
fn attribute_placeholder(param: &ParameterInfo) -> (String, String, String) {
if let Some(ref default) = param.default_value {
return (String::new(), default.clone(), String::new());
}
let hint = param.native_type_hint.as_ref().or(param.type_hint.as_ref());
let base_type = match hint {
Some(t) => match t.non_null_type() {
Some(inner) => inner,
None => t.clone(),
},
None => {
let name = param.name.strip_prefix('$').unwrap_or(¶m.name);
return (String::new(), name.to_string(), String::new());
}
};
if base_type.is_string_type() {
let name = param.name.strip_prefix('$').unwrap_or(¶m.name);
return ("'".to_string(), name.to_string(), "'".to_string());
}
if base_type.is_bool() {
return (String::new(), "false".to_string(), String::new());
}
if base_type.is_int() {
return (String::new(), "0".to_string(), String::new());
}
if base_type.is_float() {
return (String::new(), "0.0".to_string(), String::new());
}
if base_type.is_bare_array() {
return (String::new(), "[]".to_string(), String::new());
}
let name = param.name.strip_prefix('$').unwrap_or(¶m.name);
(String::new(), name.to_string(), String::new())
}
pub(crate) use super::use_edit::{analyze_use_block, build_use_edit, use_import_conflicts};
const MAGIC_METHODS: &[&str] = &[
"__construct",
"__destruct",
"__clone",
"__get",
"__set",
"__isset",
"__unset",
"__call",
"__callStatic",
"__invoke",
"__toString",
"__sleep",
"__wakeup",
"__serialize",
"__unserialize",
"__set_state",
"__debugInfo",
];
fn is_magic_method(name: &str) -> bool {
MAGIC_METHODS.iter().any(|&m| m.eq_ignore_ascii_case(name))
}
pub(crate) fn format_param_list(params: &[ParameterInfo]) -> String {
params
.iter()
.map(|p| {
let name = if p.is_reference {
format!("&{}", p.name)
} else if p.is_variadic {
format!("...{}", p.name)
} else {
p.name.clone()
};
if !p.is_required && !p.is_variadic {
format!("{} = ...", name)
} else {
name
}
})
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn build_callable_label(name: &str, params: &[ParameterInfo]) -> String {
format!("{}({})", name, format_param_list(params))
}
pub(crate) fn build_method_label(method: &MethodInfo) -> String {
build_callable_label(&method.name, &method.parameters)
}
pub(crate) fn build_completion_items(
target_class: &ClassInfo,
access_kind: AccessKind,
current_class_name: Option<&str>,
is_self_or_ancestor: bool,
uri: &str,
) -> Vec<CompletionItem> {
let same_class = current_class_name.is_some_and(|name| name == target_class.name);
let mut items: Vec<CompletionItem> = Vec::new();
for method in &target_class.methods {
let is_constructor = method.name.eq_ignore_ascii_case("__construct");
if is_magic_method(&method.name) {
let allow = is_constructor
&& is_self_or_ancestor
&& matches!(
access_kind,
AccessKind::DoubleColon | AccessKind::ParentDoubleColon
);
if !allow {
continue;
}
}
if method.visibility == Visibility::Private && !same_class {
continue;
}
if method.visibility == Visibility::Protected && !same_class && !is_self_or_ancestor {
continue;
}
let include = match access_kind {
AccessKind::Arrow => !method.is_static,
AccessKind::DoubleColon => method.is_static || is_constructor,
AccessKind::ParentDoubleColon => true,
AccessKind::Other => true,
};
if !include {
continue;
}
let label = build_method_label(method);
let return_type = method
.return_type
.as_ref()
.or(method.native_return_type.as_ref())
.map(shorten_php_type);
let data = serde_json::to_value(CompletionItemData {
class_name: target_class.name.clone(),
member_name: method.name.clone(),
kind: "method".to_string(),
uri: uri.to_string(),
extra_class_names: vec![],
})
.ok();
let class_description = Some(display_class_name(&target_class.name).to_string());
items.push(CompletionItem {
label,
label_details: Some(CompletionItemLabelDetails {
detail: None,
description: class_description,
}),
kind: Some(CompletionItemKind::METHOD),
detail: return_type,
insert_text: Some(build_callable_snippet(&method.name, &method.parameters)),
insert_text_format: Some(InsertTextFormat::SNIPPET),
filter_text: Some(method.name.clone()),
tags: deprecation_tag(method.deprecation_message.is_some()),
commit_characters: Some(METHOD_COMMIT_CHARS.iter().map(|s| s.to_string()).collect()),
data,
..CompletionItem::default()
});
}
for property in &target_class.properties {
if property.visibility == Visibility::Private && !same_class {
continue;
}
if property.visibility == Visibility::Protected && !same_class && !is_self_or_ancestor {
continue;
}
let include = match access_kind {
AccessKind::Arrow => !property.is_static,
AccessKind::DoubleColon | AccessKind::ParentDoubleColon => property.is_static,
AccessKind::Other => true,
};
if !include {
continue;
}
let display_name = if access_kind == AccessKind::DoubleColon
|| access_kind == AccessKind::ParentDoubleColon
{
format!("${}", property.name)
} else {
property.name.clone()
};
let detail = property.type_hint.as_ref().map(shorten_php_type);
let data = serde_json::to_value(CompletionItemData {
class_name: target_class.name.clone(),
member_name: property.name.clone(),
kind: "property".to_string(),
uri: uri.to_string(),
extra_class_names: vec![],
})
.ok();
let class_description = Some(display_class_name(&target_class.name).to_string());
items.push(CompletionItem {
label: display_name.clone(),
label_details: Some(CompletionItemLabelDetails {
detail: None,
description: class_description,
}),
kind: Some(CompletionItemKind::PROPERTY),
detail,
insert_text: Some(display_name.clone()),
filter_text: Some(display_name),
tags: deprecation_tag(property.deprecation_message.is_some()),
data,
..CompletionItem::default()
});
}
if access_kind == AccessKind::DoubleColon
|| access_kind == AccessKind::ParentDoubleColon
|| access_kind == AccessKind::Other
{
for constant in &target_class.constants {
if constant.visibility == Visibility::Private && !same_class {
continue;
}
if constant.visibility == Visibility::Protected && !same_class && !is_self_or_ancestor {
continue;
}
let detail = constant
.value
.clone()
.or_else(|| constant.type_hint.as_ref().map(shorten_php_type));
let data = serde_json::to_value(CompletionItemData {
class_name: target_class.name.clone(),
member_name: constant.name.clone(),
kind: "constant".to_string(),
uri: uri.to_string(),
extra_class_names: vec![],
})
.ok();
let class_description = Some(display_class_name(&target_class.name).to_string());
items.push(CompletionItem {
label: constant.name.clone(),
label_details: Some(CompletionItemLabelDetails {
detail: None,
description: class_description,
}),
kind: Some(CompletionItemKind::CONSTANT),
detail,
insert_text: Some(constant.name.clone()),
filter_text: Some(constant.name.clone()),
tags: deprecation_tag(constant.deprecation_message.is_some()),
data,
..CompletionItem::default()
});
}
}
if access_kind == AccessKind::DoubleColon || access_kind == AccessKind::ParentDoubleColon {
items.push(CompletionItem {
label: "class".to_string(),
kind: Some(CompletionItemKind::KEYWORD),
detail: Some("class-string".to_string()),
insert_text: Some("class".to_string()),
filter_text: Some("class".to_string()),
..CompletionItem::default()
});
}
items.sort_by(|a, b| {
let ka = kind_sort_tier(a.kind);
let kb = kind_sort_tier(b.kind);
ka.cmp(&kb).then_with(|| {
a.filter_text
.as_deref()
.unwrap_or(&a.label)
.to_lowercase()
.cmp(&b.filter_text.as_deref().unwrap_or(&b.label).to_lowercase())
})
});
for (i, item) in items.iter_mut().enumerate() {
item.sort_text = Some(format!("{:05}", i));
}
items
}
const METHOD_COMMIT_CHARS: &[&str] = &["("];
fn kind_sort_tier(kind: Option<CompletionItemKind>) -> u8 {
match kind {
Some(CompletionItemKind::CONSTANT) | Some(CompletionItemKind::KEYWORD) => 0,
Some(CompletionItemKind::PROPERTY) => 1,
Some(CompletionItemKind::METHOD) => 2,
_ => 3,
}
}
pub(crate) fn deprecation_tag(is_deprecated: bool) -> Option<Vec<CompletionItemTag>> {
if is_deprecated {
Some(vec![CompletionItemTag::DEPRECATED])
} else {
None
}
}
pub(crate) fn is_ancestor_of(
current_class: Option<&ClassInfo>,
target_class: &ClassInfo,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> bool {
let Some(cc) = current_class else {
return false;
};
if cc.name == target_class.name {
return true;
}
let mut ancestor_name = cc.parent_class.clone();
let mut depth = 0u32;
while let Some(ref name) = ancestor_name {
depth += 1;
if depth > 20 {
break;
}
let short = name.rsplit('\\').next().unwrap_or(name);
if name == &target_class.name || short == target_class.name {
return true;
}
ancestor_name = class_loader(name).and_then(|ci| ci.parent_class.clone());
}
false
}
pub(crate) fn build_union_completion_items(
candidates: &[Arc<ClassInfo>],
effective_access: AccessKind,
current_class: Option<&ClassInfo>,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
cache: &crate::virtual_members::ResolvedClassCache,
uri: &str,
) -> Vec<CompletionItem> {
let current_class_name = current_class.map(|cc| cc.name.as_str());
let num_candidates = candidates.len();
let mut all_items: Vec<CompletionItem> = Vec::new();
let mut occurrence_count: HashMap<String, usize> = HashMap::new();
for target_class in candidates {
let resolved =
crate::virtual_members::resolve_class_fully_cached(target_class, class_loader, cache);
let merged = if target_class.methods.len() > resolved.methods.len() {
let mut patched = (*resolved).clone();
for method in target_class.methods.iter() {
if !patched
.methods
.iter()
.any(|m| m.name == method.name && m.is_static == method.is_static)
{
patched.methods.push(method.clone());
}
}
std::sync::Arc::new(patched)
} else {
resolved
};
let self_or_ancestor = is_ancestor_of(current_class, target_class, class_loader);
let items = build_completion_items(
&merged,
effective_access,
current_class_name,
self_or_ancestor,
uri,
);
for item in items {
if let Some(existing) = all_items
.iter_mut()
.find(|existing| existing.label == item.label)
{
*occurrence_count.entry(existing.label.clone()).or_insert(1) += 1;
merge_data_class_names(existing, &item);
} else {
occurrence_count.insert(item.label.clone(), 1);
all_items.push(item);
}
}
}
merge_union_completion_items(all_items, occurrence_count, num_candidates)
}
fn merge_data_class_names(existing: &mut CompletionItem, new_item: &CompletionItem) {
let (Some(existing_data), Some(new_data)) = (&existing.data, &new_item.data) else {
return;
};
let (Ok(mut ed), Ok(nd)) = (
serde_json::from_value::<CompletionItemData>(existing_data.clone()),
serde_json::from_value::<CompletionItemData>(new_data.clone()),
) else {
return;
};
if ed.class_name == nd.class_name || ed.extra_class_names.contains(&nd.class_name) {
return;
}
ed.extra_class_names.push(nd.class_name.clone());
if let Ok(v) = serde_json::to_value(&ed) {
existing.data = Some(v);
}
}
fn class_names_from_data(item: &CompletionItem) -> Option<String> {
let data_value = item.data.as_ref()?;
let data: CompletionItemData = serde_json::from_value(data_value.clone()).ok()?;
let mut names = vec![display_class_name(&data.class_name).to_string()];
for extra in &data.extra_class_names {
names.push(display_class_name(extra).to_string());
}
Some(names.join("|"))
}
pub(crate) fn merge_union_completion_items(
items: Vec<CompletionItem>,
occurrence_count: HashMap<String, usize>,
num_candidates: usize,
) -> Vec<CompletionItem> {
if num_candidates <= 1 {
return items;
}
let sort_key = |item: &CompletionItem| -> (u8, String) {
(
kind_sort_tier(item.kind),
item.filter_text
.as_deref()
.unwrap_or(&item.label)
.to_lowercase(),
)
};
let mut intersection: Vec<CompletionItem> = Vec::new();
let mut branch_only: Vec<CompletionItem> = Vec::new();
for item in items {
let count = occurrence_count.get(&item.label).copied().unwrap_or(1);
if count >= num_candidates {
intersection.push(item);
} else {
branch_only.push(item);
}
}
intersection.sort_by_key(|item| sort_key(item));
branch_only.sort_by_key(|item| sort_key(item));
let mut result = Vec::with_capacity(intersection.len() + branch_only.len());
for (i, mut item) in intersection.into_iter().enumerate() {
item.sort_text = Some(format!("0_{:05}", i));
if let Some(class_names) = class_names_from_data(&item) {
if let Some(ref mut ld) = item.label_details {
ld.description = Some(class_names);
} else {
item.label_details = Some(CompletionItemLabelDetails {
detail: None,
description: Some(class_names),
});
}
}
result.push(item);
}
for (i, mut item) in branch_only.into_iter().enumerate() {
item.sort_text = Some(format!("1_{:05}", i));
if let Some(class_names) = class_names_from_data(&item) {
if let Some(ref mut ld) = item.label_details {
ld.description = Some(class_names);
} else {
item.label_details = Some(CompletionItemLabelDetails {
detail: None,
description: Some(class_names),
});
}
}
result.push(item);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ClassInfo;
fn item(label: &str, class_name: &str) -> CompletionItem {
let data = serde_json::to_value(CompletionItemData {
class_name: class_name.to_string(),
member_name: label.to_string(),
kind: "method".to_string(),
uri: String::new(),
extra_class_names: vec![],
})
.ok();
CompletionItem {
label: label.to_string(),
filter_text: Some(label.to_string()),
data,
..CompletionItem::default()
}
}
#[test]
fn class_names_from_data_single_class() {
let i = item("foo", "User");
assert_eq!(class_names_from_data(&i).as_deref(), Some("User"));
}
#[test]
fn class_names_from_data_with_extras() {
let mut i = item("foo", "User");
if let Some(ref data_value) = i.data {
let mut d: CompletionItemData = serde_json::from_value(data_value.clone()).unwrap();
d.extra_class_names.push("AdminUser".to_string());
i.data = serde_json::to_value(&d).ok();
}
assert_eq!(class_names_from_data(&i).as_deref(), Some("User|AdminUser"));
}
#[test]
fn class_names_from_data_none_without_data() {
let i = CompletionItem {
label: "foo".to_string(),
..CompletionItem::default()
};
assert!(class_names_from_data(&i).is_none());
}
#[test]
fn single_candidate_returns_items_unchanged() {
let items = vec![item("foo", "A"), item("bar", "A")];
let mut counts = std::collections::HashMap::new();
counts.insert("foo".to_string(), 1);
counts.insert("bar".to_string(), 1);
let result = merge_union_completion_items(items.clone(), counts, 1);
assert_eq!(result.len(), 2);
assert_eq!(result[0].label, items[0].label);
assert_eq!(result[1].label, items[1].label);
}
#[test]
fn intersection_members_sorted_before_branch_only() {
let items = vec![
item("shared", "A"),
item("unique_a", "A"),
item("unique_b", "B"),
];
let mut counts = std::collections::HashMap::new();
counts.insert("shared".to_string(), 2);
counts.insert("unique_a".to_string(), 1);
counts.insert("unique_b".to_string(), 1);
let result = merge_union_completion_items(items, counts, 2);
assert_eq!(result.len(), 3);
assert_eq!(result[0].label, "shared");
assert!(result[0].sort_text.as_deref().unwrap().starts_with("0_"));
assert!(result[1].sort_text.as_deref().unwrap().starts_with("1_"));
assert!(result[2].sort_text.as_deref().unwrap().starts_with("1_"));
}
#[test]
fn branch_only_items_get_label_details() {
let items = vec![item("only_a", "A")];
let mut counts = std::collections::HashMap::new();
counts.insert("only_a".to_string(), 1);
let result = merge_union_completion_items(items, counts, 2);
assert_eq!(result.len(), 1);
let ld = result[0]
.label_details
.as_ref()
.expect("should have label_details");
assert_eq!(ld.description.as_deref(), Some("A"));
}
#[test]
fn intersection_items_get_class_description() {
let items = vec![item("shared", "A")];
let mut counts = std::collections::HashMap::new();
counts.insert("shared".to_string(), 2);
let result = merge_union_completion_items(items, counts, 2);
assert_eq!(result.len(), 1);
let ld = result[0]
.label_details
.as_ref()
.expect("should have label_details");
assert_eq!(ld.description.as_deref(), Some("A"));
}
#[test]
fn branch_only_items_sorted_alphabetically() {
let items = vec![item("zebra", "A"), item("alpha", "A"), item("middle", "A")];
let mut counts = std::collections::HashMap::new();
counts.insert("zebra".to_string(), 1);
counts.insert("alpha".to_string(), 1);
counts.insert("middle".to_string(), 1);
let result = merge_union_completion_items(items, counts, 2);
assert_eq!(result[0].label, "alpha");
assert_eq!(result[1].label, "middle");
assert_eq!(result[2].label, "zebra");
}
#[test]
fn same_class_is_ancestor() {
let cls = ClassInfo {
name: "Foo".to_string(),
..ClassInfo::default()
};
let loader = |_: &str| -> Option<Arc<ClassInfo>> { None };
assert!(is_ancestor_of(Some(&cls), &cls, &loader));
}
#[test]
fn no_current_class_is_not_ancestor() {
let target = ClassInfo {
name: "Foo".to_string(),
..ClassInfo::default()
};
let loader = |_: &str| -> Option<Arc<ClassInfo>> { None };
assert!(!is_ancestor_of(None, &target, &loader));
}
#[test]
fn direct_parent_is_ancestor() {
let parent = ClassInfo {
name: "Parent".to_string(),
..ClassInfo::default()
};
let child = ClassInfo {
name: "Child".to_string(),
parent_class: Some("Parent".to_string()),
..ClassInfo::default()
};
let loader = |_: &str| -> Option<Arc<ClassInfo>> { None };
assert!(is_ancestor_of(Some(&child), &parent, &loader));
}
#[test]
fn grandparent_is_ancestor_via_loader() {
let grandparent = ClassInfo {
name: "GrandParent".to_string(),
..ClassInfo::default()
};
let child = ClassInfo {
name: "Child".to_string(),
parent_class: Some("Parent".to_string()),
..ClassInfo::default()
};
let loader = |name: &str| -> Option<Arc<ClassInfo>> {
if name == "Parent" {
Some(Arc::new(ClassInfo {
name: "Parent".to_string(),
parent_class: Some("GrandParent".to_string()),
..ClassInfo::default()
}))
} else {
None
}
};
assert!(is_ancestor_of(Some(&child), &grandparent, &loader));
}
#[test]
fn unrelated_class_is_not_ancestor() {
let current = ClassInfo {
name: "Foo".to_string(),
parent_class: Some("Bar".to_string()),
..ClassInfo::default()
};
let target = ClassInfo {
name: "Baz".to_string(),
..ClassInfo::default()
};
let loader = |_: &str| -> Option<Arc<ClassInfo>> { None };
assert!(!is_ancestor_of(Some(¤t), &target, &loader));
}
#[test]
fn fqn_parent_matches_short_name_target() {
let parent_target = ClassInfo {
name: "BaseService".to_string(),
..ClassInfo::default()
};
let child = ClassInfo {
name: "MyService".to_string(),
parent_class: Some("App\\BaseService".to_string()),
..ClassInfo::default()
};
let loader = |_: &str| -> Option<Arc<ClassInfo>> { None };
assert!(is_ancestor_of(Some(&child), &parent_target, &loader));
}
#[test]
fn kind_sort_tier_constants_before_properties_before_methods() {
let constant = kind_sort_tier(Some(CompletionItemKind::CONSTANT));
let keyword = kind_sort_tier(Some(CompletionItemKind::KEYWORD));
let property = kind_sort_tier(Some(CompletionItemKind::PROPERTY));
let method = kind_sort_tier(Some(CompletionItemKind::METHOD));
assert_eq!(
constant, keyword,
"constants and keywords share the same tier"
);
assert!(
constant < property,
"constants should sort before properties"
);
assert!(property < method, "properties should sort before methods");
}
#[test]
fn kind_sort_tier_none_sorts_last() {
let method = kind_sort_tier(Some(CompletionItemKind::METHOD));
let none = kind_sort_tier(None);
assert!(method < none, "None kind should sort after methods");
}
fn item_with_kind(label: &str, class_name: &str, kind: CompletionItemKind) -> CompletionItem {
let data = serde_json::to_value(CompletionItemData {
class_name: class_name.to_string(),
member_name: label.to_string(),
kind: "method".to_string(),
uri: String::new(),
extra_class_names: vec![],
})
.ok();
CompletionItem {
label: label.to_string(),
filter_text: Some(label.to_string()),
kind: Some(kind),
data,
..CompletionItem::default()
}
}
#[test]
fn merge_sorts_by_kind_then_alphabetically() {
let items = vec![
item_with_kind("alpha", "A", CompletionItemKind::METHOD),
item_with_kind("NAME", "A", CompletionItemKind::CONSTANT),
item_with_kind("color", "A", CompletionItemKind::PROPERTY),
];
let mut counts = std::collections::HashMap::new();
counts.insert("alpha".to_string(), 2);
counts.insert("NAME".to_string(), 2);
counts.insert("color".to_string(), 2);
let result = merge_union_completion_items(items, counts, 2);
assert_eq!(result.len(), 3);
assert_eq!(result[0].label, "NAME");
assert_eq!(result[1].label, "color");
assert_eq!(result[2].label, "alpha");
}
#[test]
fn merge_branch_only_sorted_by_kind_then_alphabetically() {
let items = vec![
item_with_kind("zebra", "A", CompletionItemKind::METHOD),
item_with_kind("STATUS", "A", CompletionItemKind::CONSTANT),
item_with_kind("active", "A", CompletionItemKind::PROPERTY),
item_with_kind("beta", "A", CompletionItemKind::METHOD),
];
let mut counts = std::collections::HashMap::new();
counts.insert("zebra".to_string(), 1);
counts.insert("STATUS".to_string(), 1);
counts.insert("active".to_string(), 1);
counts.insert("beta".to_string(), 1);
let result = merge_union_completion_items(items, counts, 2);
assert_eq!(result.len(), 4);
assert_eq!(result[0].label, "STATUS");
assert_eq!(result[1].label, "active");
assert_eq!(result[2].label, "beta");
assert_eq!(result[3].label, "zebra");
}
#[test]
fn intersection_kind_sorts_before_branch_only_same_kind() {
let items = vec![
item_with_kind("shared", "A", CompletionItemKind::METHOD),
item_with_kind("ONLY_A", "A", CompletionItemKind::CONSTANT),
];
let mut counts = std::collections::HashMap::new();
counts.insert("shared".to_string(), 2);
counts.insert("ONLY_A".to_string(), 1);
let result = merge_union_completion_items(items, counts, 2);
assert_eq!(result.len(), 2);
assert!(result[0].sort_text.as_deref().unwrap().starts_with("0_"));
assert!(result[1].sort_text.as_deref().unwrap().starts_with("1_"));
assert_eq!(result[0].label, "shared");
assert_eq!(result[1].label, "ONLY_A");
}
#[test]
fn deprecation_tag_returns_tag_when_deprecated() {
let tags = deprecation_tag(true);
assert!(tags.is_some());
assert!(tags.unwrap().contains(&CompletionItemTag::DEPRECATED));
}
#[test]
fn deprecation_tag_returns_none_when_not_deprecated() {
assert!(deprecation_tag(false).is_none());
}
}