use std::collections::HashMap;
use tower_lsp::lsp_types::*;
use crate::Backend;
use crate::completion::use_edit::{analyze_use_block, build_use_edit, use_import_conflicts};
use crate::diagnostics::unknown_classes::UNKNOWN_CLASS_CODE;
use crate::symbol_map::SymbolKind;
use crate::util::{is_class_keyword, short_name};
impl Backend {
pub(crate) fn collect_import_class_actions(
&self,
uri: &str,
content: &str,
params: &CodeActionParams,
out: &mut Vec<CodeActionOrCommand>,
) {
let file_use_map: HashMap<String, String> = self.file_use_map(uri);
let file_namespace: Option<String> = self.namespace_map.read().get(uri).cloned().flatten();
let symbol_map = match self.symbol_maps.read().get(uri) {
Some(sm) => sm.clone(),
None => return,
};
let local_classes: Vec<crate::types::ClassInfo> = self
.ast_map
.read()
.get(uri)
.map(|v| {
v.iter()
.map(|c| crate::types::ClassInfo::clone(c))
.collect()
})
.unwrap_or_default();
let request_start = crate::util::position_to_byte_offset(content, params.range.start);
let request_end = crate::util::position_to_byte_offset(content, params.range.end);
let affinity_table = crate::completion::class_completion::build_affinity_table(
&file_use_map,
&file_namespace,
);
for span in &symbol_map.spans {
if span.start as usize >= request_end || span.end as usize <= request_start {
continue;
}
let (ref_name, is_fqn) = match &span.kind {
SymbolKind::ClassReference { name, is_fqn } => (name.as_str(), *is_fqn),
_ => continue,
};
if is_fqn || ref_name.contains('\\') {
continue;
}
if file_use_map.contains_key(ref_name) {
continue;
}
if local_classes.iter().any(|c| c.name == ref_name) {
continue;
}
if let Some(ns) = &file_namespace {
let ns_qualified = format!("{}\\{}", ns, ref_name);
if self.find_or_load_class(&ns_qualified).is_some() {
continue;
}
}
if file_namespace.is_none() && self.find_or_load_class(ref_name).is_some() {
continue;
}
let candidates = self.find_import_candidates(ref_name, &affinity_table);
if candidates.is_empty() {
continue;
}
let use_block = analyze_use_block(content);
let doc_uri: Url = match uri.parse() {
Ok(u) => u,
Err(_) => continue,
};
let matching_diagnostics: Vec<Diagnostic> = params
.context
.diagnostics
.iter()
.filter(|d| {
matches!(
&d.code,
Some(NumberOrString::String(code)) if code == UNKNOWN_CLASS_CODE
)
})
.cloned()
.collect();
for fqn in &candidates {
if use_import_conflicts(fqn, &file_use_map) {
continue;
}
let edits = match build_use_edit(fqn, &use_block, &file_namespace) {
Some(e) => e,
None => continue,
};
let title = format!("Import `{}`", fqn);
let mut changes = HashMap::new();
changes.insert(doc_uri.clone(), edits);
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title,
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: if matching_diagnostics.is_empty() {
None
} else {
Some(matching_diagnostics.clone())
},
edit: Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
}),
command: None,
is_preferred: if candidates.len() == 1 {
Some(true)
} else {
None
},
disabled: None,
data: None,
}));
}
break;
}
self.collect_import_from_static_access(
uri,
content,
params,
request_start,
request_end,
&file_use_map,
&file_namespace,
&local_classes,
&symbol_map,
out,
);
}
#[allow(clippy::too_many_arguments)]
fn collect_import_from_static_access(
&self,
uri: &str,
content: &str,
_params: &CodeActionParams,
request_start: usize,
request_end: usize,
file_use_map: &HashMap<String, String>,
file_namespace: &Option<String>,
local_classes: &[crate::types::ClassInfo],
symbol_map: &crate::symbol_map::SymbolMap,
out: &mut Vec<CodeActionOrCommand>,
) {
let affinity_table =
crate::completion::class_completion::build_affinity_table(file_use_map, file_namespace);
for span in &symbol_map.spans {
if span.start as usize >= request_end || span.end as usize <= request_start {
continue;
}
let subject = match &span.kind {
SymbolKind::MemberAccess {
subject_text,
is_static: true,
..
} => subject_text.as_str(),
_ => continue,
};
if subject.starts_with('$') || subject.contains('\\') || is_class_keyword(subject) {
continue;
}
if file_use_map.contains_key(subject) {
continue;
}
if local_classes.iter().any(|c| c.name == subject) {
continue;
}
if let Some(ns) = file_namespace {
let ns_qualified = format!("{}\\{}", ns, subject);
if self.find_or_load_class(&ns_qualified).is_some() {
continue;
}
}
if file_namespace.is_none() && self.find_or_load_class(subject).is_some() {
continue;
}
let candidates = self.find_import_candidates(subject, &affinity_table);
if candidates.is_empty() {
continue;
}
let use_block = analyze_use_block(content);
let doc_uri: Url = match uri.parse() {
Ok(u) => u,
Err(_) => continue,
};
for fqn in &candidates {
if use_import_conflicts(fqn, file_use_map) {
continue;
}
let edits = match build_use_edit(fqn, &use_block, file_namespace) {
Some(e) => e,
None => continue,
};
let title = format!("Import `{}`", fqn);
let mut changes = HashMap::new();
changes.insert(doc_uri.clone(), edits);
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title,
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: None,
edit: Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
}),
command: None,
is_preferred: if candidates.len() == 1 {
Some(true)
} else {
None
},
disabled: None,
data: None,
}));
}
break;
}
}
fn find_import_candidates(
&self,
name: &str,
affinity_table: &HashMap<String, u32>,
) -> Vec<String> {
let mut candidates = Vec::new();
let name_lower = name.to_lowercase();
{
let idx = self.class_index.read();
for fqn in idx.keys() {
if short_name(fqn).to_lowercase() == name_lower {
candidates.push(fqn.clone());
}
}
}
{
let cmap = self.classmap.read();
for fqn in cmap.keys() {
if short_name(fqn).to_lowercase() == name_lower
&& !candidates
.iter()
.any(|c: &String| c.eq_ignore_ascii_case(fqn))
{
candidates.push(fqn.clone());
}
}
}
{
let amap = self.ast_map.read();
let nmap = self.namespace_map.read();
for (file_uri, classes) in amap.iter() {
let ns = nmap.get(file_uri).and_then(|o| o.as_deref());
for cls in classes {
if cls.name.to_lowercase() == name_lower {
let fqn = match ns {
Some(ns) => format!("{}\\{}", ns, cls.name),
None => cls.name.clone(),
};
if !candidates
.iter()
.any(|c: &String| c.eq_ignore_ascii_case(&fqn))
{
candidates.push(fqn);
}
}
}
}
}
let stub_idx = self.stub_index.read();
for &stub_name in stub_idx.keys() {
if short_name(stub_name).to_lowercase() == name_lower
&& !candidates
.iter()
.any(|c: &String| c.eq_ignore_ascii_case(stub_name))
{
candidates.push(stub_name.to_string());
}
}
candidates.sort();
candidates.dedup();
candidates.sort_by(|a, b| {
let score_a = crate::completion::class_completion::affinity_score(a, affinity_table);
let score_b = crate::completion::class_completion::affinity_score(b, affinity_table);
score_b.cmp(&score_a).then_with(|| a.cmp(b))
});
candidates
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_candidates_from_classmap() {
let backend = crate::Backend::new_test();
{
let mut cmap = backend.classmap.write();
cmap.insert(
"App\\Models\\User".to_string(),
"/fake/path/User.php".into(),
);
cmap.insert(
"App\\Http\\Request".to_string(),
"/fake/path/Request.php".into(),
);
}
let table = std::collections::HashMap::new();
let candidates = backend.find_import_candidates("User", &table);
assert!(candidates.contains(&"App\\Models\\User".to_string()));
assert!(!candidates.contains(&"App\\Http\\Request".to_string()));
}
#[test]
fn find_candidates_case_insensitive() {
let backend = crate::Backend::new_test();
{
let mut cmap = backend.classmap.write();
cmap.insert(
"Vendor\\Obscure\\ZYGOMORPHIC".to_string(),
"/fake/path.php".into(),
);
}
let table = std::collections::HashMap::new();
let candidates = backend.find_import_candidates("Zygomorphic", &table);
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0], "Vendor\\Obscure\\ZYGOMORPHIC");
}
#[test]
fn find_candidates_deduplicates() {
let backend = crate::Backend::new_test();
{
let mut idx = backend.class_index.write();
idx.insert("App\\Foo".to_string(), "file:///foo.php".to_string());
}
{
let mut cmap = backend.classmap.write();
cmap.insert("App\\Foo".to_string(), "/foo.php".into());
}
let table = std::collections::HashMap::new();
let candidates = backend.find_import_candidates("Foo", &table);
let count = candidates.iter().filter(|c| *c == "App\\Foo").count();
assert_eq!(count, 1, "should not have duplicates");
}
#[test]
fn import_action_offered_for_unresolved_class() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nnamespace App;\n\nnew Request();\n";
backend.update_ast(uri, content);
{
let mut cmap = backend.classmap.write();
cmap.insert(
"Illuminate\\Http\\Request".to_string(),
"/vendor/laravel/framework/src/Illuminate/Http/Request.php".into(),
);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(3, 4),
end: Position::new(3, 11),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
assert!(
actions.iter().any(|a| {
if let CodeActionOrCommand::CodeAction(ca) = a {
ca.title.contains("Illuminate\\Http\\Request")
} else {
false
}
}),
"expected an import action for Illuminate\\Http\\Request, got: {:?}",
actions
.iter()
.map(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.clone(),
CodeActionOrCommand::Command(c) => c.title.clone(),
})
.collect::<Vec<_>>()
);
}
#[test]
fn no_import_action_when_already_imported() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nnamespace App;\n\nuse Illuminate\\Http\\Request;\n\nnew Request();\n";
backend.update_ast(uri, content);
{
let mut cmap = backend.classmap.write();
cmap.insert(
"Illuminate\\Http\\Request".to_string(),
"/vendor/laravel/framework/src/Illuminate/Http/Request.php".into(),
);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(5, 4),
end: Position::new(5, 11),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let import_actions: Vec<_> = actions
.iter()
.filter(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.starts_with("Import"),
_ => false,
})
.collect();
assert!(
import_actions.is_empty(),
"should not offer import when already imported, got: {:?}",
import_actions
);
}
#[test]
fn no_import_action_for_fqn_reference() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nnamespace App;\n\nnew \\Illuminate\\Http\\Request();\n";
backend.update_ast(uri, content);
{
let mut cmap = backend.classmap.write();
cmap.insert(
"Illuminate\\Http\\Request".to_string(),
"/vendor/laravel/framework/src/Illuminate/Http/Request.php".into(),
);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(3, 5),
end: Position::new(3, 35),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let import_actions: Vec<_> = actions
.iter()
.filter(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.starts_with("Import"),
_ => false,
})
.collect();
assert!(
import_actions.is_empty(),
"should not offer import for FQN reference"
);
}
#[test]
fn import_action_inserts_use_statement() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nnamespace App;\n\nnew Request();\n";
backend.update_ast(uri, content);
{
let mut cmap = backend.classmap.write();
cmap.insert(
"Illuminate\\Http\\Request".to_string(),
"/vendor/laravel/framework/src/Illuminate/Http/Request.php".into(),
);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(3, 4),
end: Position::new(3, 11),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let action = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca)
if ca.title.contains("Illuminate\\Http\\Request") =>
{
Some(ca)
}
_ => None,
})
.expect("expected import action");
let edit = action.edit.as_ref().expect("expected workspace edit");
let changes = edit.changes.as_ref().expect("expected changes");
let file_edits = changes
.get(&uri.parse::<Url>().unwrap())
.expect("expected edits for the file");
assert_eq!(file_edits.len(), 1);
assert_eq!(file_edits[0].new_text, "use Illuminate\\Http\\Request;\n");
}
#[test]
fn import_skips_conflict_with_existing_import() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nnamespace App;\n\nuse Symfony\\Component\\HttpFoundation\\Request;\n\nnew Request();\n";
backend.update_ast(uri, content);
{
let mut cmap = backend.classmap.write();
cmap.insert(
"Illuminate\\Http\\Request".to_string(),
"/vendor/laravel/framework/src/Illuminate/Http/Request.php".into(),
);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(5, 4),
end: Position::new(5, 11),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let import_actions: Vec<_> = actions
.iter()
.filter(|a| match a {
CodeActionOrCommand::CodeAction(ca) => {
ca.title.contains("Illuminate\\Http\\Request")
}
_ => false,
})
.collect();
assert!(
import_actions.is_empty(),
"should not offer conflicting import"
);
}
#[test]
fn import_action_offered_in_no_namespace_file_for_new_expression() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\n\nnew Request();\n";
backend.update_ast(uri, content);
{
let mut cmap = backend.classmap.write();
cmap.insert(
"Illuminate\\Http\\Request".to_string(),
"/vendor/laravel/framework/src/Illuminate/Http/Request.php".into(),
);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 4),
end: Position::new(2, 11),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
assert!(
actions.iter().any(|a| {
if let CodeActionOrCommand::CodeAction(ca) = a {
ca.title.contains("Illuminate\\Http\\Request")
} else {
false
}
}),
"expected an import action for Illuminate\\Http\\Request in no-namespace file, got: {:?}",
actions
.iter()
.map(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.clone(),
CodeActionOrCommand::Command(c) => c.title.clone(),
})
.collect::<Vec<_>>()
);
}
#[test]
fn import_action_offered_in_no_namespace_file_for_static_call() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\n\nfunction () {\n return Carbon::now();\n};\n";
backend.update_ast(uri, content);
{
let mut cmap = backend.classmap.write();
cmap.insert(
"Carbon\\Carbon".to_string(),
"/vendor/nesbot/carbon/src/Carbon/Carbon.php".into(),
);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(3, 11),
end: Position::new(3, 17),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
assert!(
actions.iter().any(|a| {
if let CodeActionOrCommand::CodeAction(ca) = a {
ca.title.contains("Carbon\\Carbon")
} else {
false
}
}),
"expected an import action for Carbon\\Carbon in no-namespace file, got: {:?}",
actions
.iter()
.map(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.clone(),
CodeActionOrCommand::Command(c) => c.title.clone(),
})
.collect::<Vec<_>>()
);
}
#[test]
fn import_action_inserts_use_after_php_open_in_no_namespace_file() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\n\nnew Request();\n";
backend.update_ast(uri, content);
{
let mut cmap = backend.classmap.write();
cmap.insert(
"Illuminate\\Http\\Request".to_string(),
"/vendor/laravel/framework/src/Illuminate/Http/Request.php".into(),
);
}
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 4),
end: Position::new(2, 11),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let action = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca)
if ca.title.contains("Illuminate\\Http\\Request") =>
{
Some(ca)
}
_ => None,
})
.expect("expected import action");
let edit = action.edit.as_ref().expect("expected workspace edit");
let changes = edit.changes.as_ref().expect("expected changes");
let file_edits = changes
.get(&uri.parse::<Url>().unwrap())
.expect("expected edits for the file");
assert_eq!(file_edits.len(), 1);
assert_eq!(file_edits[0].new_text, "use Illuminate\\Http\\Request;\n");
assert_eq!(file_edits[0].range.start.line, 1);
}
#[test]
fn no_import_action_for_known_global_class_in_no_namespace_file() {
let backend = crate::Backend::new_test();
let uri_dep = "file:///dep.php";
let content_dep = "<?php\nclass Helper {}\n";
backend.update_ast(uri_dep, content_dep);
{
let mut idx = backend.class_index.write();
idx.insert("Helper".to_string(), uri_dep.to_string());
}
let uri = "file:///test.php";
let content = "<?php\n\nnew Helper();\n";
backend.update_ast(uri, content);
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 4),
end: Position::new(2, 10),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
let import_actions: Vec<_> = actions
.iter()
.filter(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.starts_with("Import"),
_ => false,
})
.collect();
assert!(
import_actions.is_empty(),
"should not offer import for a known global class in no-namespace file, got: {:?}",
import_actions
.iter()
.map(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.clone(),
_ => String::new(),
})
.collect::<Vec<_>>()
);
}
#[test]
fn import_action_offered_when_namespaced_class_in_ast_map() {
let backend = crate::Backend::new_test();
let uri_dep = "file:///vendor/carbon.php";
let content_dep = "<?php\nnamespace Carbon;\n\nclass Carbon {}\n";
backend.update_ast(uri_dep, content_dep);
{
let mut idx = backend.class_index.write();
idx.insert("Carbon\\Carbon".to_string(), uri_dep.to_string());
}
let uri = "file:///test.php";
let content = "<?php\n\nfunction () {\n return Carbon::now();\n};\n";
backend.update_ast(uri, content);
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(3, 11),
end: Position::new(3, 17),
},
context: CodeActionContext {
diagnostics: vec![],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let actions = backend.handle_code_action(uri, content, ¶ms);
assert!(
actions.iter().any(|a| {
if let CodeActionOrCommand::CodeAction(ca) = a {
ca.title.contains("Carbon\\Carbon")
} else {
false
}
}),
"expected an import action for Carbon\\Carbon when the namespaced class is in ast_map, got: {:?}",
actions
.iter()
.map(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.clone(),
CodeActionOrCommand::Command(c) => c.title.clone(),
})
.collect::<Vec<_>>()
);
}
}