mod attribute;
use attribute::attribute_completions;
mod include_path;
use include_path::{include_path_completions, include_path_prefix};
mod keyword;
pub use keyword::{keyword_completions, magic_constant_completions};
mod match_arm;
use match_arm::match_arm_completions;
mod member;
use member::{
all_instance_members, all_static_members, magic_method_completions, resolve_receiver_class,
resolve_static_receiver,
};
mod namespace;
use namespace::{
collect_classes_with_ns, collect_fqns_with_prefix, current_file_namespace, typed_prefix,
use_completion_prefix, use_insert_position,
};
mod symbols;
pub use symbols::{
builtin_completions, superglobal_completions, symbol_completions, symbol_completions_before,
};
use std::sync::Arc;
use tower_lsp::lsp_types::{
CompletionItem, CompletionItemKind, InsertTextFormat, Position, Range, TextEdit, Url,
};
use tower_lsp::lsp_types::{Documentation, MarkupContent, MarkupKind};
use crate::document::ast::{ParsedDoc, format_type_hint};
use crate::hover::format_params_str;
use crate::lang::docblock::find_docblock;
use crate::lang::phpstorm_meta::PhpStormMeta;
use crate::text::{camel_sort_key, utf16_offset_to_byte};
use crate::types::type_map::{TypeMap, enclosing_class_at, params_of_function, params_of_method};
use std::collections::HashMap;
fn callable_item(label: &str, kind: CompletionItemKind, has_params: bool) -> CompletionItem {
if has_params {
CompletionItem {
label: label.to_string(),
kind: Some(kind),
insert_text: Some(format!("{}($1)", label)),
insert_text_format: Some(InsertTextFormat::SNIPPET),
..Default::default()
}
} else {
CompletionItem {
label: label.to_string(),
kind: Some(kind),
insert_text: Some(format!("{}()", label)),
..Default::default()
}
}
}
fn named_arg_item(
label: &str,
kind: CompletionItemKind,
params: &[php_ast::Param<'_, '_>],
) -> Option<CompletionItem> {
if params.is_empty() {
return None;
}
let named_label = format!(
"{}({})",
label,
params
.iter()
.map(|p| format!("{}:", &p.name.to_string()))
.collect::<Vec<_>>()
.join(", ")
);
let snippet = format!(
"{}({})",
label,
params
.iter()
.enumerate()
.map(|(i, p)| format!("{}: ${}", p.name, i + 1))
.collect::<Vec<_>>()
.join(", ")
);
Some(CompletionItem {
label: named_label,
kind: Some(kind),
insert_text: Some(snippet),
insert_text_format: Some(InsertTextFormat::SNIPPET),
detail: Some("named args".to_string()),
..Default::default()
})
}
fn build_function_sig(
name: &str,
params: &[php_ast::Param<'_, '_>],
return_type: Option<&php_ast::TypeHint<'_, '_>>,
) -> String {
let params_str = format_params_str(params);
let ret = return_type
.map(|r| format!(": {}", format_type_hint(r)))
.unwrap_or_default();
format!("function {}({}){}", name, params_str, ret)
}
fn docblock_docs(doc: &ParsedDoc, sym_name: &str) -> Option<Documentation> {
let db = find_docblock(&doc.program().stmts, sym_name)?;
let md = db.to_markdown();
if md.is_empty() {
None
} else {
Some(Documentation::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value: md,
}))
}
}
fn resolve_attribute_class(source: &str, position: Position) -> 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].trim_end_matches('(').trim_end();
let hash_pos = before.rfind("#[")?;
let after_bracket = before[hash_pos + 2..].trim_start();
let name: String = after_bracket
.trim_start_matches('\\')
.rsplit('\\')
.next()
.unwrap_or("")
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if name.is_empty() { None } else { Some(name) }
}
fn resolve_call_params(
source: &str,
doc: &ParsedDoc,
other_docs: &[Arc<ParsedDoc>],
position: Position,
) -> Vec<String> {
let line = match source.lines().nth(position.line as usize) {
Some(l) => l,
None => return vec![],
};
let col = utf16_offset_to_byte(line, position.character as usize);
let before = &line[..col];
let before = before.strip_suffix('(').unwrap_or(before);
let func_name: String = before
.chars()
.rev()
.take_while(|&c| c.is_alphanumeric() || c == '_')
.collect::<String>()
.chars()
.rev()
.collect();
if func_name.is_empty() {
return vec![];
}
let mut params = params_of_function(doc, &func_name);
if params.is_empty() {
for other in other_docs {
params = params_of_function(other, &func_name);
if !params.is_empty() {
break;
}
}
}
params
}
pub type ClassDocLookup<'a> = &'a dyn Fn(&str) -> Option<Arc<ParsedDoc>>;
#[derive(Default)]
pub struct CompletionCtx<'a> {
pub source: Option<&'a str>,
pub position: Option<Position>,
pub meta: Option<&'a PhpStormMeta>,
pub doc_uri: Option<&'a Url>,
pub file_imports: Option<&'a HashMap<String, String>>,
pub find_class_doc: Option<ClassDocLookup<'a>>,
pub analysis: Option<&'a mir_analyzer::FileAnalysis>,
pub type_map: Option<&'a dyn Fn() -> Arc<TypeMap>>,
pub session: Option<std::sync::Arc<mir_analyzer::AnalysisSession>>,
}
fn whole_doc_type_map(
ctx: &CompletionCtx<'_>,
doc: &ParsedDoc,
meta: Option<&PhpStormMeta>,
) -> Arc<TypeMap> {
match ctx.type_map {
Some(get) => get(),
None => Arc::new(TypeMap::from_doc_with_meta(doc, meta)),
}
}
pub(crate) fn cursor_in_string_or_comment(source: &str, cursor_byte: usize) -> bool {
#[derive(PartialEq)]
enum S {
Normal,
Single,
Double,
Line,
Block,
}
let bytes = source.as_bytes();
let limit = bytes.len().min(cursor_byte);
let mut i = 0usize;
let mut state = S::Normal;
while i < limit {
match state {
S::Normal => match bytes[i] {
b'\'' => {
state = S::Single;
i += 1;
}
b'"' => {
state = S::Double;
i += 1;
}
b'/' if i + 1 < limit && bytes[i + 1] == b'/' => {
state = S::Line;
i += 2;
}
b'#' if !(i + 1 < limit && bytes[i + 1] == b'[') => {
state = S::Line;
i += 1;
}
b'/' if i + 1 < limit && bytes[i + 1] == b'*' => {
state = S::Block;
i += 2;
}
_ => {
i += 1;
}
},
S::Single => match bytes[i] {
b'\\' => {
i += 2;
}
b'\'' => {
state = S::Normal;
i += 1;
}
_ => {
i += 1;
}
},
S::Double => match bytes[i] {
b'\\' => {
i += 2;
}
b'"' => {
state = S::Normal;
i += 1;
}
_ => {
i += 1;
}
},
S::Line => {
if bytes[i] == b'\n' {
state = S::Normal;
}
i += 1;
}
S::Block => {
if bytes[i] == b'*' && i + 1 < limit && bytes[i + 1] == b'/' {
state = S::Normal;
i += 2;
} else {
i += 1;
}
}
}
}
state != S::Normal
}
pub fn filtered_completions_at(
doc: &ParsedDoc,
other_docs: &[Arc<ParsedDoc>],
trigger_character: Option<&str>,
ctx: &CompletionCtx<'_>,
) -> Vec<CompletionItem> {
let source = ctx.source;
let position = ctx.position;
let doc_uri = ctx.doc_uri;
if let (Some(src), Some(pos)) = (source, position) {
let cursor_byte = doc.view().byte_of_position(pos) as usize;
if cursor_in_string_or_comment(src, cursor_byte) && include_path_prefix(src, pos).is_none()
{
return vec![];
}
}
let meta = ctx.meta;
let empty_imports = HashMap::new();
let imports = ctx.file_imports.unwrap_or(&empty_imports);
match trigger_character {
Some("$") => {
let mut items = superglobal_completions();
items.extend(
symbol_completions(doc)
.into_iter()
.filter(|i| i.kind == Some(CompletionItemKind::VARIABLE)),
);
items
}
Some(">") => {
if let (Some(src), Some(pos)) = (source, position) {
let type_map = whole_doc_type_map(ctx, doc, meta);
if let Some(class_names) =
resolve_receiver_class(src, doc, pos, ctx.analysis, &type_map)
{
let mut items = Vec::new();
let mut seen = std::collections::HashSet::new();
for class_name in class_names.split('|') {
let class_name = class_name.trim();
for item in all_instance_members(
class_name,
doc,
other_docs,
ctx.find_class_doc,
ctx.session.as_deref(),
) {
if seen.insert(item.label.clone()) {
items.push(item);
}
}
}
if !items.is_empty() {
return items;
}
}
}
symbol_completions(doc)
.into_iter()
.filter(|i| i.kind == Some(CompletionItemKind::METHOD))
.collect()
}
Some(":") => {
if let (Some(src), Some(pos)) = (source, position)
&& let Some(class_name) =
resolve_static_receiver(src, doc, other_docs, pos, imports)
{
let items = all_static_members(
&class_name,
doc,
other_docs,
ctx.find_class_doc,
ctx.session.as_deref(),
);
if !items.is_empty() {
return items;
}
}
vec![]
}
Some("[") => {
if let (Some(src), Some(pos)) = (source, position) {
let line = src.lines().nth(pos.line as usize).unwrap_or("");
let col = utf16_offset_to_byte(line, pos.character as usize);
let before = &line[..col];
if before.trim_end_matches('[').trim_end().ends_with('#') {
return attribute_completions(src, pos, doc, other_docs, imports);
}
}
vec![]
}
Some("(") => {
if let (Some(src), Some(pos)) = (source, position) {
let params = resolve_call_params(src, doc, other_docs, pos);
if !params.is_empty() {
return params
.into_iter()
.map(|p| CompletionItem {
label: format!("{p}:"),
kind: Some(CompletionItemKind::VARIABLE),
..Default::default()
})
.collect();
}
if let Some(attr_class) = resolve_attribute_class(src, pos) {
let mut attr_params = params_of_method(doc, &attr_class, "__construct");
if attr_params.is_empty() {
for other in other_docs {
attr_params = params_of_method(other, &attr_class, "__construct");
if !attr_params.is_empty() {
break;
}
}
}
if !attr_params.is_empty() {
return attr_params
.into_iter()
.map(|p| CompletionItem {
label: format!("{p}:"),
kind: Some(CompletionItemKind::VARIABLE),
detail: Some(format!("#{attr_class} argument")),
..Default::default()
})
.collect();
}
}
}
vec![]
}
_ => {
if let (Some(src), Some(pos)) = (source, position) {
let line = src.lines().nth(pos.line as usize).unwrap_or("");
let col = utf16_offset_to_byte(line, pos.character as usize);
let before = &line[..col];
let pre_colon = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
if pre_colon.ends_with("::") {
let colon_end_char = pre_colon.encode_utf16().count() as u32;
let colon_pos = tower_lsp::lsp_types::Position {
line: pos.line,
character: colon_end_char,
};
if let Some(class_name) =
resolve_static_receiver(src, doc, other_docs, colon_pos, imports)
{
let items = all_static_members(
&class_name,
doc,
other_docs,
ctx.find_class_doc,
ctx.session.as_deref(),
);
if !items.is_empty() {
return items;
}
}
}
}
if let (Some(src), Some(pos)) = (source, position) {
let line = src.lines().nth(pos.line as usize).unwrap_or("");
let col = utf16_offset_to_byte(line, pos.character as usize);
let before = &line[..col];
let pre_arrow = before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_');
let has_arrow = pre_arrow.ends_with("->") || pre_arrow.ends_with("?->");
if has_arrow {
let arrow_end_char = pre_arrow.encode_utf16().count() as u32;
let arrow_pos = tower_lsp::lsp_types::Position {
line: pos.line,
character: arrow_end_char,
};
let type_map = whole_doc_type_map(ctx, doc, meta);
if let Some(cls) =
resolve_receiver_class(src, doc, arrow_pos, ctx.analysis, &type_map)
{
let mut items = Vec::new();
let mut seen = std::collections::HashSet::new();
for class_name in cls.split('|') {
for item in all_instance_members(
class_name.trim(),
doc,
other_docs,
ctx.find_class_doc,
ctx.session.as_deref(),
) {
if seen.insert(item.label.clone()) {
items.push(item);
}
}
}
if !items.is_empty() {
let prefix = before.strip_prefix(pre_arrow).unwrap_or("").to_string();
if !prefix.is_empty() {
let fq = crate::text::FuzzyQuery::new(&prefix);
items.retain(|i| {
let match_against = if i.label.starts_with('$') {
i.label.strip_prefix('$').unwrap_or(&i.label)
} else {
&i.label
};
fq.camel_match(match_against)
});
for item in &mut items {
let match_against = if item.label.starts_with('$') {
item.label.strip_prefix('$').unwrap_or(&item.label)
} else {
&item.label
};
item.sort_text =
Some(crate::text::camel_sort_key(&prefix, match_against));
item.filter_text = Some(item.label.clone());
}
}
return items;
}
}
}
}
if let (Some(src), Some(pos)) = (source, position) {
let line = src.lines().nth(pos.line as usize).unwrap_or("");
let col = utf16_offset_to_byte(line, pos.character as usize);
let before = &line[..col];
let pre_ident =
before.trim_end_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '\\');
if pre_ident.trim_end().ends_with("#[") || pre_ident.trim_end() == "#[" {
let items = attribute_completions(src, pos, doc, other_docs, imports);
if !items.is_empty() {
return items;
}
}
}
if let (Some(src), Some(pos)) = (source, position)
&& let Some(use_prefix) = use_completion_prefix(src, pos)
{
let mut use_items: Vec<CompletionItem> = Vec::new();
for other in other_docs {
collect_fqns_with_prefix(
&other.program().stmts,
"",
&use_prefix,
&mut use_items,
);
}
collect_fqns_with_prefix(&doc.program().stmts, "", &use_prefix, &mut use_items);
if !use_items.is_empty() {
return use_items;
}
}
if let (Some(src), Some(pos), Some(uri)) = (source, position, doc_uri)
&& let Some(prefix) = include_path_prefix(src, pos)
{
let items = include_path_completions(uri, &prefix);
return items;
}
let other_classes_cell: std::cell::OnceCell<
Vec<Vec<(String, CompletionItemKind, String)>>,
> = std::cell::OnceCell::new();
let other_classes = || {
other_classes_cell.get_or_init(|| {
other_docs
.iter()
.map(|other| {
let mut classes = Vec::new();
collect_classes_with_ns(&other.program().stmts, "", &mut classes);
classes
})
.collect()
})
};
if let (Some(src), Some(pos)) = (source, position)
&& let Some(prefix) = typed_prefix(Some(src), Some(pos))
&& prefix.contains('\\')
{
let is_use = use_completion_prefix(src, pos).is_some();
if !is_use {
let prefix_lc = prefix.trim_start_matches('\\').to_lowercase();
let mut ns_items: Vec<CompletionItem> = Vec::new();
for classes in other_classes() {
for (label, kind, fqn) in classes {
if fqn
.get(..prefix_lc.len())
.is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
{
ns_items.push(CompletionItem {
label: label.clone(),
kind: Some(*kind),
insert_text: Some(label.clone()),
detail: Some(fqn.clone()),
..Default::default()
});
}
}
}
let mut classes = Vec::new();
collect_classes_with_ns(&doc.program().stmts, "", &mut classes);
for (label, kind, fqn) in classes {
if fqn
.get(..prefix_lc.len())
.is_some_and(|s| s.eq_ignore_ascii_case(&prefix_lc))
{
ns_items.push(CompletionItem {
label: label.clone(),
kind: Some(kind),
insert_text: Some(label),
detail: Some(fqn),
..Default::default()
});
}
}
if !ns_items.is_empty() {
return ns_items;
}
}
}
if let (Some(src), Some(pos)) = (source, position)
&& let Some(match_items) = match_arm_completions(
src,
doc,
other_docs,
pos,
&|| whole_doc_type_map(ctx, doc, meta),
ctx.analysis,
)
&& !match_items.is_empty()
{
let mut all = match_items;
let mut normal_items = keyword_completions();
normal_items.extend(magic_constant_completions());
normal_items.extend(builtin_completions());
normal_items.extend(superglobal_completions());
normal_items.extend(symbol_completions(doc));
all.extend(normal_items);
let mut seen = std::collections::HashSet::new();
all.retain(|i| seen.insert(i.label.clone()));
return all;
}
let mut magic_items: Vec<CompletionItem> = Vec::new();
if let (Some(src), Some(pos)) = (source, position)
&& enclosing_class_at(src, doc, pos).is_some()
{
magic_items.extend(magic_method_completions());
}
let mut items = keyword_completions();
items.extend(magic_constant_completions());
items.extend(builtin_completions());
items.extend(superglobal_completions());
let sym_items = if let (Some(_src), Some(pos)) = (source, position) {
symbol_completions_before(doc, pos.line)
} else {
symbol_completions(doc)
};
items.extend(sym_items);
items.extend(magic_items);
let cur_ns = current_file_namespace(&doc.program().stmts);
for (other, classes) in other_docs.iter().zip(other_classes()) {
for (label, kind, fqn) in classes {
let additional_text_edits = if let Some(src) = source {
let in_same_ns =
!cur_ns.is_empty() && *fqn == format!("{}\\{}", cur_ns, label);
let is_global = !fqn.contains('\\');
let already = imports.contains_key(label);
if !in_same_ns && !is_global && !already {
let pos = use_insert_position(src);
Some(vec![TextEdit {
range: Range {
start: pos,
end: pos,
},
new_text: format!("use {};\n", fqn),
}])
} else {
None
}
} else {
None
};
items.push(CompletionItem {
label: label.clone(),
kind: Some(*kind),
detail: if fqn.contains('\\') {
Some(fqn.clone())
} else {
None
},
additional_text_edits,
..Default::default()
});
}
let cross: Vec<CompletionItem> = symbol_completions(other)
.into_iter()
.filter(|i| {
!matches!(
i.kind,
Some(CompletionItemKind::CLASS)
| Some(CompletionItemKind::INTERFACE)
| Some(CompletionItemKind::ENUM)
) && i.kind != Some(CompletionItemKind::VARIABLE)
})
.collect();
items.extend(cross);
}
let mut seen = std::collections::HashSet::new();
items.retain(|i| seen.insert(i.label.clone()));
let prefix = typed_prefix(source, position).unwrap_or_default();
if prefix.contains('\\') {
let ns_prefix = prefix.trim_start_matches('\\').to_lowercase();
items.retain(|i| {
let fqn = i.detail.as_deref().unwrap_or(&i.label);
fqn.get(..ns_prefix.len())
.is_some_and(|s| s.eq_ignore_ascii_case(&ns_prefix))
});
} else if !prefix.is_empty() {
let fq = crate::text::FuzzyQuery::new(&prefix);
items.retain(|i| fq.camel_match(&i.label));
for item in &mut items {
item.sort_text = Some(camel_sort_key(&prefix, &item.label));
item.filter_text = Some(item.label.clone());
}
}
items
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(source: &str) -> ParsedDoc {
ParsedDoc::parse(source.to_string())
}
fn labels(items: &[CompletionItem]) -> Vec<&str> {
items.iter().map(|i| i.label.as_str()).collect()
}
#[test]
fn keywords_list_is_non_empty() {
let kws = keyword_completions();
assert!(
kws.len() >= 20,
"expected at least 20 keywords, got {}",
kws.len()
);
}
#[test]
fn keywords_contain_common_php_keywords() {
let kws = keyword_completions();
let ls = labels(&kws);
for expected in &[
"function",
"class",
"return",
"foreach",
"match",
"namespace",
] {
assert!(ls.contains(expected), "missing keyword: {expected}");
}
}
#[test]
fn all_keyword_items_have_keyword_kind() {
for item in keyword_completions() {
assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD));
}
}
#[test]
fn magic_constants_all_present() {
let items = magic_constant_completions();
let ls: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
for name in &[
"__FILE__",
"__DIR__",
"__LINE__",
"__CLASS__",
"__FUNCTION__",
"__METHOD__",
"__NAMESPACE__",
"__TRAIT__",
] {
assert!(ls.contains(name), "missing magic constant: {name}");
}
}
#[test]
fn magic_constants_have_constant_kind() {
for item in magic_constant_completions() {
assert_eq!(
item.kind,
Some(CompletionItemKind::CONSTANT),
"{} should have CONSTANT kind",
item.label
);
}
}
#[test]
fn resolve_attribute_class_extracts_name() {
let src = "<?php\n#[Route(\n";
let pos = Position {
line: 1,
character: 8,
};
let result = resolve_attribute_class(src, pos);
assert_eq!(result.as_deref(), Some("Route"));
}
#[test]
fn resolve_attribute_class_fqn_extracts_short_name() {
let src = "<?php\n#[\\Symfony\\Component\\Routing\\Route(\n";
let pos = Position {
line: 1,
character: 38,
};
let result = resolve_attribute_class(src, pos);
assert_eq!(result.as_deref(), Some("Route"));
}
#[test]
fn resolve_attribute_class_returns_none_for_regular_call() {
let src = "<?php\nsomeFunction(\n";
let pos = Position {
line: 1,
character: 14,
};
let result = resolve_attribute_class(src, pos);
assert!(result.is_none(), "should not match regular function call");
}
}