use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use mir_analyzer::Visibility;
use mir_analyzer::db::ClassLike;
use tower_lsp_server::ls_types::{CompletionItem, CompletionItemKind, InsertTextFormat, Position};
use crate::document::ast::ParsedDoc;
use crate::document::document_store::DocumentStore;
use crate::navigation::moniker::resolve_fqn;
use crate::text::utf16_offset_to_byte;
use crate::types::type_map::{
ClassMembers, enclosing_class_at, members_of_class, mixin_classes_of, parent_class_name,
resolve_class_ref,
};
use super::callable_item;
pub(super) fn all_instance_members(
class_name: &str,
doc: &ParsedDoc,
other_docs: &[Arc<ParsedDoc>],
find_class_doc: Option<super::ClassDocLookup<'_>>,
session: Option<&DocumentStore>,
in_class_scope: bool,
) -> Vec<CompletionItem> {
all_members(
class_name,
doc,
other_docs,
find_class_doc,
session,
false,
in_class_scope,
)
}
pub(super) fn all_static_members(
class_name: &str,
doc: &ParsedDoc,
other_docs: &[Arc<ParsedDoc>],
find_class_doc: Option<super::ClassDocLookup<'_>>,
session: Option<&DocumentStore>,
in_class_scope: bool,
) -> Vec<CompletionItem> {
all_members(
class_name,
doc,
other_docs,
find_class_doc,
session,
true,
in_class_scope,
)
}
fn all_members(
class_name: &str,
doc: &ParsedDoc,
other_docs: &[Arc<ParsedDoc>],
find_class_doc: Option<super::ClassDocLookup<'_>>,
session: Option<&DocumentStore>,
is_static: bool,
in_class_scope: bool,
) -> Vec<CompletionItem> {
let all: Vec<&ParsedDoc> = std::iter::once(doc)
.chain(other_docs.iter().map(|d| d.as_ref()))
.collect();
let mut items = Vec::new();
let mut seen_names: HashSet<String> = HashSet::new();
let mut visited: HashSet<String> = HashSet::new();
let mut queue: Vec<(String, bool)> = vec![(class_name.to_string(), false)];
let mut is_root = true;
while let Some((current, inherited)) = queue.pop() {
if !visited.insert(current.clone()) {
continue;
}
let class_like = session.and_then(|docs| mir_class_like(docs, doc, ¤t, is_root));
is_root = false;
if let Some(class_like) = class_like {
push_mir_members(
&class_like,
inherited,
is_static,
in_class_scope,
&mut items,
&mut seen_names,
);
let own_traits = class_like.class_traits();
let mut next: Vec<(String, bool)> = class_like
.ancestor_fqcns()
.into_iter()
.map(|a| {
let via_own_trait = own_traits.contains(&a);
(a.to_string(), inherited || !via_own_trait)
})
.collect();
if !is_static {
next.extend(class_like.mixins().iter().map(|m| (m.to_string(), true)));
}
queue.extend(next.into_iter().rev());
continue;
}
let short: &str = current.rsplit('\\').next().unwrap_or(¤t);
let fast_doc: Option<Arc<ParsedDoc>> = find_class_doc.and_then(|f| f(¤t));
let fast_ref: Option<&ParsedDoc> = fast_doc.as_deref();
let defining: Option<(&ParsedDoc, ClassMembers)> = if let Some(fd) = fast_ref {
let m = members_of_class(fd, ¤t);
m.found.then_some((fd, m))
} else {
all.iter().find_map(|d| {
let m = members_of_class(d, ¤t);
m.found.then_some((*d, m))
})
};
let Some((d, members)) = defining else {
continue;
};
let parent = members
.parent
.as_deref()
.map(|p| resolve_class_ref(d, short, p));
let hidden = |restricted: &[(String, bool)], name: &str| {
inherited
&& restricted
.iter()
.any(|(n, private)| n == name && (*private || !in_class_scope))
};
for (name, meth_is_static, has_params) in members.methods {
if (meth_is_static == is_static)
&& !hidden(&members.restricted_methods, &name)
&& seen_names.insert(name.clone())
{
let mut item = callable_item(&name, CompletionItemKind::METHOD, has_params);
item.data = Some(serde_json::json!({ "class": current }));
items.push(item);
}
}
for (name, prop_is_static) in &members.properties {
if *prop_is_static == is_static && !hidden(&members.restricted_properties, name) {
let label = format!("${name}");
if seen_names.insert(label.clone()) {
let detail = if !is_static && members.readonly_properties.contains(name) {
Some("readonly".to_string())
} else {
None
};
let insert_text = if is_static { None } else { Some(name.clone()) };
items.push(CompletionItem {
label,
kind: Some(CompletionItemKind::PROPERTY),
detail,
insert_text,
..Default::default()
});
}
}
}
if is_static {
for name in members.constants {
if !hidden(&members.restricted_constants, &name) && seen_names.insert(name.clone())
{
items.push(CompletionItem {
label: name,
kind: Some(CompletionItemKind::CONSTANT),
..Default::default()
});
}
}
} else {
for mixin in mixin_classes_of(d, short) {
queue.push((resolve_class_ref(d, short, &mixin), true));
}
}
for trait_name in members.trait_uses {
queue.push((resolve_class_ref(d, short, &trait_name), inherited));
}
if let Some(p) = parent {
queue.push((p, true));
}
}
items
}
fn mir_class_like(
docs: &DocumentStore,
doc: &ParsedDoc,
name: &str,
is_root: bool,
) -> Option<ClassLike> {
if !is_root {
return docs.find_class_like(name);
}
mir_name_candidates(doc, name).find_map(|fqcn| docs.find_class_like(&fqcn))
}
pub(super) fn mir_method_params(
docs: &DocumentStore,
doc: &ParsedDoc,
class: &str,
method: &str,
) -> Option<Vec<String>> {
let method = mir_name_candidates(doc, class)
.find_map(|fqcn| docs.find_method_in_chain(&fqcn, method))?;
Some(
method
.params
.iter()
.map(|p| p.name.as_str().trim_start_matches('$'))
.filter(|name| *name != "...")
.map(str::to_owned)
.collect(),
)
}
fn mir_name_candidates(doc: &ParsedDoc, name: &str) -> impl Iterator<Item = String> {
let resolved = (!name.contains('\\'))
.then(|| resolve_fqn(doc, name, &doc.file_imports()))
.filter(|resolved| resolved != name);
resolved.into_iter().chain(std::iter::once(name.to_owned()))
}
fn push_mir_members(
class_like: &ClassLike,
inherited: bool,
is_static: bool,
in_class_scope: bool,
items: &mut Vec<CompletionItem>,
seen_names: &mut HashSet<String>,
) {
let hidden = |visibility: Visibility| {
inherited
&& match visibility {
Visibility::Public => false,
Visibility::Protected => !in_class_scope,
Visibility::Private => true,
}
};
let owner = class_like.fqcn().to_string();
for method in class_like.own_methods().values() {
if method.is_static == is_static
&& !hidden(method.visibility)
&& seen_names.insert(method.name.to_string())
{
let mut item = callable_item(
&method.name,
CompletionItemKind::METHOD,
!method.params.is_empty(),
);
item.data = Some(serde_json::json!({ "class": owner }));
items.push(item);
}
}
for prop in class_like
.own_properties()
.into_iter()
.flat_map(|p| p.values())
{
if prop.is_static != is_static || hidden(prop.visibility) {
continue;
}
let label = format!("${}", prop.name);
if seen_names.insert(label.clone()) {
let detail = (!is_static && prop.has_native_readonly).then(|| "readonly".to_string());
let insert_text = (!is_static).then(|| prop.name.to_string());
items.push(CompletionItem {
label,
kind: Some(CompletionItemKind::PROPERTY),
detail,
insert_text,
..Default::default()
});
}
}
if is_static {
let cases = match class_like {
ClassLike::Enum(e) => e.cases.keys().cloned().collect(),
_ => Vec::new(),
};
let constants = class_like
.own_constants()
.values()
.filter(|c| !c.visibility.is_some_and(hidden))
.map(|c| c.name.clone());
for name in cases.into_iter().chain(constants) {
if seen_names.insert(name.to_string()) {
items.push(CompletionItem {
label: name.to_string(),
kind: Some(CompletionItemKind::CONSTANT),
..Default::default()
});
}
}
}
}
pub(super) fn resolve_static_receiver(
source: &str,
doc: &ParsedDoc,
other_docs: &[Arc<ParsedDoc>],
position: Position,
imports: &HashMap<String, String>,
) -> Option<String> {
let line = source.lines().nth(position.line as usize)?;
let col = utf16_offset_to_byte(line, position.character as usize);
let before = &line[..col];
let before = before.strip_suffix("::").unwrap_or(before);
let name: String = before
.chars()
.rev()
.take_while(|&c| c.is_alphanumeric() || c == '_' || c == '\\')
.collect::<String>()
.chars()
.rev()
.collect();
match name.as_str() {
"" => None,
"self" | "static" => enclosing_class_at(source, doc, position),
"parent" => {
let enclosing = enclosing_class_at(source, doc, position)?;
if let Some(p) = parent_class_name(doc, &enclosing) {
return Some(p);
}
for other in other_docs {
if let Some(p) = parent_class_name(other, &enclosing) {
return Some(p);
}
}
None
}
_ => {
if name.contains('\\') {
return Some(name.trim_start_matches('\\').to_owned());
}
if let Some(fqcn) = imports.get(&name) {
return Some(fqcn.trim_start_matches('\\').to_owned());
}
Some(name)
}
}
}
const PHP_MAGIC_METHODS: &[(&str, &str)] = &[
(
"__construct",
"public function __construct($1)\n{\n $2\n}",
),
("__destruct", "public function __destruct()\n{\n $1\n}"),
(
"__get",
"public function __get(string $name): mixed\n{\n $1\n}",
),
(
"__set",
"public function __set(string $name, mixed $value): void\n{\n $1\n}",
),
(
"__isset",
"public function __isset(string $name): bool\n{\n $1\n}",
),
(
"__unset",
"public function __unset(string $name): void\n{\n $1\n}",
),
(
"__call",
"public function __call(string $name, array $arguments): mixed\n{\n $1\n}",
),
(
"__callStatic",
"public static function __callStatic(string $name, array $arguments): mixed\n{\n $1\n}",
),
(
"__toString",
"public function __toString(): string\n{\n $1\n}",
),
(
"__invoke",
"public function __invoke($1): mixed\n{\n $2\n}",
),
("__clone", "public function __clone(): void\n{\n $1\n}"),
("__sleep", "public function __sleep(): array\n{\n $1\n}"),
("__wakeup", "public function __wakeup(): void\n{\n $1\n}"),
(
"__serialize",
"public function __serialize(): array\n{\n $1\n}",
),
(
"__unserialize",
"public function __unserialize(array $data): void\n{\n $1\n}",
),
(
"__debugInfo",
"public function __debugInfo(): ?array\n{\n $1\n}",
),
];
pub(super) fn magic_method_completions() -> Vec<CompletionItem> {
PHP_MAGIC_METHODS
.iter()
.map(|(name, snippet)| CompletionItem {
label: name.to_string(),
kind: Some(CompletionItemKind::METHOD),
insert_text: Some(snippet.to_string()),
insert_text_format: Some(InsertTextFormat::SNIPPET),
detail: Some("magic method".to_string()),
..Default::default()
})
.collect()
}
pub(super) fn resolve_receiver_class(
source: &str,
doc: &ParsedDoc,
position: Position,
analysis: Option<&mir_analyzer::FileAnalysis>,
) -> Option<Vec<String>> {
let line = source.lines().nth(position.line as usize)?;
let col = utf16_offset_to_byte(line, position.character as usize);
let before = &line[..col];
let before = before
.strip_suffix("?->")
.or_else(|| before.strip_suffix("->"))
.unwrap_or(before);
if let Some(class_name) = extract_new_class_before_arrow(before) {
if class_name.starts_with('\\') || class_name.contains('\\') {
return Some(vec![class_name.trim_start_matches('\\').to_owned()]);
}
let imports = doc.file_imports();
if let Some(fqcn) = imports.get(&class_name) {
return Some(vec![fqcn.trim_start_matches('\\').to_owned()]);
}
if let Some((_, fqcn)) = imports
.iter()
.find(|(alias, _)| alias.eq_ignore_ascii_case(&class_name))
{
return Some(vec![fqcn.trim_start_matches('\\').to_owned()]);
}
return Some(vec![class_name]);
}
let var_name: String = before
.chars()
.rev()
.take_while(|&c| c.is_alphanumeric() || c == '_' || c == '$')
.collect::<String>()
.chars()
.rev()
.collect();
if var_name.is_empty() {
let chain_offset = line_byte_offset(doc, position.line, before.len());
return analysis.and_then(|a| receiver_class_at(a, chain_offset));
}
let var_name = if var_name.starts_with('$') {
var_name
} else {
format!("${var_name}")
};
let var_offset = line_byte_offset(doc, position.line, before.len());
if var_name == "$this" {
return enclosing_class_at(source, doc, position)
.map(|c| vec![c])
.or_else(|| analysis.and_then(|a| receiver_class_at(a, var_offset)));
}
analysis.and_then(|a| receiver_class_at(a, var_offset))
}
pub(super) fn receiver_class_at(
analysis: &mir_analyzer::FileAnalysis,
var_offset: u32,
) -> Option<Vec<String>> {
let ty = crate::types::type_query::type_at_offset(analysis, var_offset)?;
let names = crate::types::type_query::class_names(ty);
(!names.is_empty()).then_some(names)
}
pub(super) fn line_byte_offset(doc: &ParsedDoc, line: u32, byte_in_line: usize) -> u32 {
let line_start = doc.view().byte_of_position(Position { line, character: 0 });
line_start + byte_in_line.saturating_sub(1) as u32
}
fn extract_new_class_before_arrow(text: &str) -> Option<String> {
let text = text.trim_end();
let inner = if let Some(without_last) = text.strip_suffix(')') {
if let Some(pos) = without_last.rfind("(new ") {
&without_last[pos + 1..]
} else if let Some(pos) = without_last.rfind("(new\t") {
&without_last[pos + 1..]
} else {
text
}
} else {
text
};
let inner = inner.trim();
if !inner.starts_with("new ") && !inner.starts_with("new\t") {
return None;
}
let after_new = inner[3..].trim_start();
let class: String = after_new
.chars()
.take_while(|&c| c.is_alphanumeric() || c == '_' || c == '\\')
.collect();
if class.is_empty() {
return None;
}
Some(class)
}