use std::collections::HashMap;
use tower_lsp::lsp_types::*;
use crate::Backend;
use crate::code_actions::{CodeActionData, make_code_action_data};
use crate::parser::with_parsed_program;
use crate::scope_collector::{ScopeMap, collect_function_scope, collect_scope};
use crate::util::{find_identical_occurrences, offset_to_position, position_to_byte_offset};
fn strip_outer_parens(s: &str) -> &str {
let bytes = s.as_bytes();
if bytes.len() < 2 || bytes[0] != b'(' || bytes[bytes.len() - 1] != b')' {
return s;
}
let mut depth: u32 = 0;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 && i < bytes.len() - 1 {
return s;
}
}
_ => {}
}
}
s[1..s.len() - 1].trim()
}
fn is_valid_expression(selected_text: &str) -> bool {
let trimmed = selected_text.trim();
if trimmed.is_empty() {
return false;
}
if !trimmed.starts_with('$')
&& !trimmed.starts_with('\'')
&& !trimmed.starts_with('"')
&& !trimmed.starts_with('[')
&& !trimmed.starts_with('(')
&& !trimmed.starts_with("new ")
&& !trimmed.starts_with("clone ")
&& !trimmed.starts_with("fn(")
&& !trimmed.starts_with("fn (")
&& !trimmed.starts_with("function")
&& !trimmed.starts_with("match")
&& !trimmed.starts_with("yield")
&& !trimmed.starts_with("throw")
&& !trimmed.starts_with('!')
&& !trimmed.starts_with('-')
&& !trimmed.starts_with('~')
&& !trimmed.starts_with('\\')
&& !trimmed.starts_with("self::")
&& !trimmed.starts_with("static::")
&& !trimmed.starts_with("parent::")
{
let first_char = trimmed.as_bytes()[0];
let is_numeric = first_char.is_ascii_digit();
let is_keyword = matches!(
trimmed,
"true" | "false" | "null" | "self" | "static" | "parent"
);
let has_call_parens = trimmed.contains('(');
let has_double_colon = trimmed.contains("::");
let is_all_upper_const = trimmed.chars().all(|c| c.is_ascii_uppercase() || c == '_');
if !is_numeric
&& !is_keyword
&& !has_call_parens
&& !has_double_colon
&& !is_all_upper_const
{
return false;
}
}
let body = trimmed.strip_suffix(';').unwrap_or(trimmed);
if contains_unquoted_semicolon(body) {
return false;
}
let wrapper = format!("<?php $__x = {};", body);
let arena = bumpalo::Bump::new();
let file_id = mago_database::file::FileId::new("extract_check.php");
let program = mago_syntax::parser::parse_file_content(&arena, file_id, &wrapper);
program.errors.is_empty()
}
fn contains_unquoted_semicolon(text: &str) -> bool {
let mut in_single = false;
let mut in_double = false;
let mut prev_backslash = false;
for ch in text.chars() {
if prev_backslash {
prev_backslash = false;
continue;
}
if ch == '\\' {
prev_backslash = true;
continue;
}
match ch {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
';' if !in_single && !in_double => return true,
_ => {}
}
}
false
}
fn is_entire_assignment_rhs(content: &str, start: usize, end: usize) -> bool {
let before = &content[..start];
let line_start = match before.rfind('\n') {
Some(pos) => pos + 1,
None => 0,
};
let line_end = content[end..]
.find('\n')
.map_or(content.len(), |pos| end + pos);
let line = &content[line_start..line_end];
let line_trimmed = line.trim();
let selected = content[start..end].trim();
if let Some(eq_pos) = line_trimmed.find('=') {
let before_eq = if eq_pos > 0 {
line_trimmed.as_bytes()[eq_pos - 1]
} else {
b' '
};
let after_eq = if eq_pos + 1 < line_trimmed.len() {
line_trimmed.as_bytes()[eq_pos + 1]
} else {
b' '
};
if before_eq != b'!'
&& before_eq != b'<'
&& before_eq != b'>'
&& after_eq != b'='
&& after_eq != b'>'
{
let rhs_part = line_trimmed[eq_pos + 1..].trim();
if rhs_part == format!("{};", selected) {
return true;
}
}
}
false
}
fn is_entire_expression_statement(content: &str, start: usize, end: usize) -> bool {
let selected = content[start..end].trim();
if selected.is_empty() {
return false;
}
let expr = selected.strip_suffix(';').unwrap_or(selected).trim();
if expr.is_empty() {
return false;
}
let expr_end = end.saturating_sub(1).max(start);
let line_start = content[..expr_end].rfind('\n').map_or(0, |pos| pos + 1);
let line_end = content[end..]
.find('\n')
.map_or(content.len(), |pos| end + pos);
let line_trimmed = content[line_start..line_end].trim();
let with_semi = format!("{};", expr);
line_trimmed == with_semi || line_trimmed == expr
}
fn generate_variable_name(expression: &str) -> String {
let expr = expression.trim();
if let Some(name) = extract_method_call_name(expr) {
return name;
}
if let Some(name) = extract_property_name(expr) {
return name;
}
if let Some(name) = extract_static_call_name(expr) {
return name;
}
if let Some(name) = extract_function_call_name(expr) {
return name;
}
"variable".to_string()
}
fn extract_method_call_name(expr: &str) -> Option<String> {
let name_part = find_last_member_access(expr)?;
let ident = name_part.split('(').next()?;
let ident = ident.trim();
if ident.is_empty() || !name_part.contains('(') {
return None;
}
let stripped = strip_accessor_prefix(ident);
Some(to_camel_case(stripped))
}
fn extract_property_name(expr: &str) -> Option<String> {
let name_part = find_last_member_access(expr)?;
if name_part.contains('(') {
return None;
}
let ident = name_part.trim();
if ident.is_empty() {
return None;
}
Some(to_camel_case(ident))
}
fn extract_static_call_name(expr: &str) -> Option<String> {
let double_colon = find_top_level_double_colon(expr)?;
let after = &expr[double_colon + 2..];
let ident = after.split('(').next()?.trim();
if ident.is_empty() {
return None;
}
if !after.contains('(') {
let stripped = ident.strip_prefix('$').unwrap_or(ident);
return Some(to_camel_case(stripped));
}
Some(to_camel_case(ident))
}
fn extract_function_call_name(expr: &str) -> Option<String> {
let paren_pos = expr.find('(')?;
let before = expr[..paren_pos].trim();
let ident = before.rsplit('\\').next().unwrap_or(before);
if ident.is_empty() || !ident.chars().next()?.is_alphabetic() {
return None;
}
if !ident.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
Some(snake_to_camel(ident))
}
fn find_last_member_access(expr: &str) -> Option<String> {
let mut depth_paren = 0i32;
let mut depth_bracket = 0i32;
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut last_arrow_end = None;
let bytes = expr.as_bytes();
let mut i = 0;
while i < bytes.len() {
let ch = bytes[i];
if (in_single_quote || in_double_quote) && ch == b'\\' {
i += 2;
continue;
}
if ch == b'\'' && !in_double_quote {
in_single_quote = !in_single_quote;
} else if ch == b'"' && !in_single_quote {
in_double_quote = !in_double_quote;
}
if in_single_quote || in_double_quote {
i += 1;
continue;
}
match ch {
b'(' => depth_paren += 1,
b')' => depth_paren -= 1,
b'[' => depth_bracket += 1,
b']' => depth_bracket -= 1,
b'-' if depth_paren == 0 && depth_bracket == 0 => {
if i + 1 < bytes.len() && bytes[i + 1] == b'>' {
last_arrow_end = Some(i + 2);
i += 2;
continue;
}
}
b'?' if depth_paren == 0 && depth_bracket == 0 => {
if i + 2 < bytes.len() && bytes[i + 1] == b'-' && bytes[i + 2] == b'>' {
last_arrow_end = Some(i + 3);
i += 3;
continue;
}
}
_ => {}
}
i += 1;
}
let arrow_end = last_arrow_end?;
let after = &expr[arrow_end..];
if after.is_empty() {
return None;
}
Some(after.to_string())
}
fn find_top_level_double_colon(expr: &str) -> Option<usize> {
let mut depth_paren = 0i32;
let mut depth_bracket = 0i32;
let mut in_single_quote = false;
let mut in_double_quote = false;
let bytes = expr.as_bytes();
let mut i = 0;
while i < bytes.len() {
let ch = bytes[i];
if (in_single_quote || in_double_quote) && ch == b'\\' {
i += 2;
continue;
}
if ch == b'\'' && !in_double_quote {
in_single_quote = !in_single_quote;
} else if ch == b'"' && !in_single_quote {
in_double_quote = !in_double_quote;
}
if in_single_quote || in_double_quote {
i += 1;
continue;
}
match ch {
b'(' => depth_paren += 1,
b')' => depth_paren -= 1,
b'[' => depth_bracket += 1,
b']' => depth_bracket -= 1,
b':' if depth_paren == 0 && depth_bracket == 0 => {
if i + 1 < bytes.len() && bytes[i + 1] == b':' {
return Some(i);
}
}
_ => {}
}
i += 1;
}
None
}
fn strip_accessor_prefix(name: &str) -> &str {
for prefix in &["get", "is", "has"] {
if let Some(rest) = name.strip_prefix(prefix) {
if rest.starts_with(|c: char| c.is_uppercase()) {
return rest;
}
}
}
name
}
fn to_camel_case(s: &str) -> String {
if s.is_empty() {
return "variable".to_string();
}
if s.contains('_') {
return snake_to_camel(s);
}
let mut chars = s.chars();
let first = chars.next().unwrap();
let mut result = first.to_lowercase().to_string();
result.extend(chars);
result
}
fn snake_to_camel(s: &str) -> String {
let parts: Vec<&str> = s.split('_').filter(|p| !p.is_empty()).collect();
if parts.is_empty() {
return "variable".to_string();
}
let mut result = parts[0].to_lowercase();
for part in &parts[1..] {
let mut chars = part.chars();
if let Some(first) = chars.next() {
result.extend(first.to_uppercase());
result.push_str(&chars.as_str().to_lowercase());
}
}
result
}
fn deduplicate_name(name: &str, existing_vars: &[String]) -> String {
let candidate = format!("${}", name);
if !existing_vars.contains(&candidate) {
return name.to_string();
}
for i in 1..100 {
let numbered = format!("${}{}", name, i);
if !existing_vars.contains(&numbered) {
return format!("{}{}", name, i);
}
}
name.to_string()
}
fn find_enclosing_statement_line(content: &str, selection_start: usize) -> (usize, String) {
let before = &content[..selection_start];
let line_start = match before.rfind('\n') {
Some(pos) => pos + 1,
None => 0,
};
let line_content = &content[line_start..];
let indent_len = line_content
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.count();
let indentation = line_content[..indent_len].to_string();
(line_start, indentation)
}
fn build_scope_map(content: &str, offset: u32) -> ScopeMap {
use mago_syntax::ast::*;
with_parsed_program(content, "extract_variable", |program, _content| {
for stmt in program.statements.iter() {
if let Statement::Function(func) = stmt {
let body_start = func.body.left_brace.start.offset;
let body_end = func.body.right_brace.end.offset;
if offset >= body_start && offset <= body_end {
return collect_function_scope(
&func.parameter_list,
func.body.statements.as_slice(),
body_start,
body_end,
);
}
}
if let Statement::Class(class) = stmt {
for member in class.members.iter() {
if let ClassLikeMember::Method(method) = member
&& let MethodBody::Concrete(block) = &method.body
{
let body_start = block.left_brace.start.offset;
let body_end = block.right_brace.end.offset;
if offset >= body_start && offset <= body_end {
return collect_function_scope(
&method.parameter_list,
block.statements.as_slice(),
body_start,
body_end,
);
}
}
}
}
if let Statement::Namespace(ns) = stmt {
for inner_stmt in ns.statements().iter() {
if let Statement::Function(func) = inner_stmt {
let body_start = func.body.left_brace.start.offset;
let body_end = func.body.right_brace.end.offset;
if offset >= body_start && offset <= body_end {
return collect_function_scope(
&func.parameter_list,
func.body.statements.as_slice(),
body_start,
body_end,
);
}
}
if let Statement::Class(class) = inner_stmt {
for member in class.members.iter() {
if let ClassLikeMember::Method(method) = member
&& let MethodBody::Concrete(block) = &method.body
{
let body_start = block.left_brace.start.offset;
let body_end = block.right_brace.end.offset;
if offset >= body_start && offset <= body_end {
return collect_function_scope(
&method.parameter_list,
block.statements.as_slice(),
body_start,
body_end,
);
}
}
}
}
}
}
}
let body_end = content.len() as u32;
collect_scope(program.statements.as_slice(), 0, body_end)
})
}
impl Backend {
pub(crate) fn collect_extract_variable_actions(
&self,
uri: &str,
content: &str,
params: &CodeActionParams,
out: &mut Vec<CodeActionOrCommand>,
) {
if params.range.start == params.range.end {
return;
}
let start_offset = position_to_byte_offset(content, params.range.start);
let end_offset = position_to_byte_offset(content, params.range.end);
if start_offset >= end_offset || end_offset > content.len() {
return;
}
let selected_text = &content[start_offset..end_offset];
if selected_text.trim().is_empty() {
return;
}
if !is_valid_expression(selected_text) {
return;
}
let trimmed_check = selected_text.trim();
if trimmed_check.starts_with('$')
&& trimmed_check[1..]
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return;
}
{
let before = &content[..start_offset];
let before_trimmed = before.trim_end();
if before_trimmed.ends_with("->")
|| before_trimmed.ends_with("?->")
|| before_trimmed.ends_with("::")
{
return;
}
}
if is_entire_expression_statement(content, start_offset, end_offset) {
return;
}
if is_entire_assignment_rhs(content, start_offset, end_offset) {
return;
}
let trimmed = selected_text.trim();
let has_other_occurrences = {
let first = content.find(trimmed);
match first {
Some(pos) => content[pos + trimmed.len()..].contains(trimmed),
None => false,
}
};
let title = if has_other_occurrences {
"Extract variable (this occurrence)"
} else {
"Extract variable"
};
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title: title.to_string(),
kind: Some(CodeActionKind::REFACTOR_EXTRACT),
diagnostics: None,
edit: None,
command: None,
is_preferred: Some(false),
disabled: None,
data: Some(make_code_action_data(
"refactor.extractVariable",
uri,
¶ms.range,
serde_json::json!({ "all_occurrences": false }),
)),
}));
if has_other_occurrences {
out.push(CodeActionOrCommand::CodeAction(CodeAction {
title: "Extract variable (all occurrences)".to_string(),
kind: Some(CodeActionKind::REFACTOR_EXTRACT),
diagnostics: None,
edit: None,
command: None,
is_preferred: Some(false),
disabled: None,
data: Some(make_code_action_data(
"refactor.extractVariableAll",
uri,
¶ms.range,
serde_json::json!({ "all_occurrences": true }),
)),
}));
}
}
pub(crate) fn resolve_extract_variable(
&self,
data: &CodeActionData,
content: &str,
) -> Option<WorkspaceEdit> {
let all_occurrences = data
.extra
.get("all_occurrences")
.and_then(|v| v.as_bool())
.unwrap_or(data.action_kind == "refactor.extractVariableAll");
let start_offset = position_to_byte_offset(content, data.range.start);
let end_offset = position_to_byte_offset(content, data.range.end);
if start_offset >= end_offset || end_offset > content.len() {
return None;
}
let selected_text = &content[start_offset..end_offset];
let trimmed = selected_text.trim();
if trimmed.is_empty() || !is_valid_expression(trimmed) {
return None;
}
let base_name = generate_variable_name(selected_text);
let scope_map = build_scope_map(content, start_offset as u32);
let existing_vars = scope_map.variables_in_scope(start_offset as u32);
let var_name = deduplicate_name(&base_name, &existing_vars);
let rhs = strip_outer_parens(trimmed);
let replacement_text = format!("${}", var_name);
let doc_uri: Url = match data.uri.parse() {
Ok(u) => u,
Err(_) => return None,
};
if all_occurrences {
let (scope_start, scope_end) = scope_map
.enclosing_frame(start_offset as u32)
.map(|f| (f.start as usize, f.end as usize))
.unwrap_or((0, content.len()));
let trim_start_delta = selected_text.len() - selected_text.trim_start().len();
let trim_end_delta = selected_text.len() - selected_text.trim_end().len();
let trimmed_start = start_offset + trim_start_delta;
let trimmed_end = end_offset - trim_end_delta;
let other_occurrences = find_identical_occurrences(
content,
trimmed,
trimmed_start,
trimmed_end,
scope_start,
scope_end,
);
let mut all_offsets: Vec<(usize, usize)> = vec![(start_offset, end_offset)];
all_offsets.extend(&other_occurrences);
all_offsets.sort_by_key(|&(s, _)| s);
let (first_start, _) = all_offsets[0];
let (first_line_start, first_indent) =
find_enclosing_statement_line(content, first_start);
let insert_text = format!("{}${} = {};\n", first_indent, var_name, rhs);
let insert_pos = offset_to_position(content, first_line_start);
let mut edits = vec![TextEdit {
range: Range {
start: insert_pos,
end: insert_pos,
},
new_text: insert_text,
}];
for &(occ_start, occ_end) in &all_offsets {
let start_pos = offset_to_position(content, occ_start);
let end_pos = offset_to_position(content, occ_end);
edits.push(TextEdit {
range: Range {
start: start_pos,
end: end_pos,
},
new_text: replacement_text.clone(),
});
}
let mut changes = HashMap::new();
changes.insert(doc_uri, edits);
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
} else {
let (line_start, indentation) = find_enclosing_statement_line(content, start_offset);
let insert_text = format!("{}${} = {};\n", indentation, var_name, rhs);
let insert_pos = offset_to_position(content, line_start);
let edit_insert = TextEdit {
range: Range {
start: insert_pos,
end: insert_pos,
},
new_text: insert_text,
};
let edit_replace = TextEdit {
range: data.range,
new_text: replacement_text,
};
let mut changes = HashMap::new();
changes.insert(doc_uri, vec![edit_insert, edit_replace]);
Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_from_method_call() {
assert_eq!(generate_variable_name("$user->getName()"), "name");
}
#[test]
fn name_from_method_call_no_prefix() {
assert_eq!(generate_variable_name("$user->email()"), "email");
}
#[test]
fn name_from_method_call_with_args() {
assert_eq!(generate_variable_name("$repo->findById($id)"), "findById");
}
#[test]
fn name_from_property_access() {
assert_eq!(generate_variable_name("$user->email"), "email");
}
#[test]
fn name_from_nullsafe_method() {
assert_eq!(generate_variable_name("$user?->getName()"), "name");
}
#[test]
fn name_from_nullsafe_property() {
assert_eq!(generate_variable_name("$user?->email"), "email");
}
#[test]
fn name_from_static_call() {
assert_eq!(generate_variable_name("Carbon::now()"), "now");
}
#[test]
fn name_from_static_call_namespaced() {
assert_eq!(generate_variable_name("\\Carbon\\Carbon::now()"), "now");
}
#[test]
fn name_from_function_call() {
assert_eq!(
generate_variable_name("array_filter($items, $fn)"),
"arrayFilter"
);
}
#[test]
fn name_from_simple_function() {
assert_eq!(generate_variable_name("count($items)"), "count");
}
#[test]
fn name_from_namespaced_function() {
assert_eq!(
generate_variable_name("App\\Helpers\\format_name($s)"),
"formatName"
);
}
#[test]
fn name_fallback_for_expression() {
assert_eq!(generate_variable_name("$a + $b"), "variable");
}
#[test]
fn name_fallback_for_string_literal() {
assert_eq!(generate_variable_name("'hello world'"), "variable");
}
#[test]
fn name_fallback_for_number() {
assert_eq!(generate_variable_name("42"), "variable");
}
#[test]
fn name_from_chained_method_call() {
assert_eq!(
generate_variable_name("$query->where('x', 1)->first()"),
"first"
);
}
#[test]
fn name_from_get_prefix_method() {
assert_eq!(generate_variable_name("$user->getEmail()"), "email");
}
#[test]
fn name_from_is_prefix_method() {
assert_eq!(generate_variable_name("$user->isActive()"), "active");
}
#[test]
fn name_from_has_prefix_method() {
assert_eq!(
generate_variable_name("$user->hasPermission()"),
"permission"
);
}
#[test]
fn name_no_strip_island() {
assert_eq!(generate_variable_name("$map->island()"), "island");
}
#[test]
fn deduplicate_no_collision() {
let existing = vec!["$foo".to_string(), "$bar".to_string()];
assert_eq!(deduplicate_name("name", &existing), "name");
}
#[test]
fn deduplicate_with_collision() {
let existing = vec!["$name".to_string(), "$foo".to_string()];
assert_eq!(deduplicate_name("name", &existing), "name1");
}
#[test]
fn deduplicate_multiple_collisions() {
let existing = vec![
"$name".to_string(),
"$name1".to_string(),
"$name2".to_string(),
];
assert_eq!(deduplicate_name("name", &existing), "name3");
}
#[test]
fn find_statement_line_simple() {
let content = "<?php\n $x = $user->getName();\n";
let offset = content.find("$user").unwrap();
let (line_start, indent) = find_enclosing_statement_line(content, offset);
assert_eq!(line_start, 6); assert_eq!(indent, " ");
}
#[test]
fn find_statement_line_no_indent() {
let content = "<?php\n$x = foo();\n";
let offset = content.find("foo").unwrap();
let (line_start, indent) = find_enclosing_statement_line(content, offset);
assert_eq!(line_start, 6);
assert_eq!(indent, "");
}
#[test]
fn find_statement_line_tab_indent() {
let content = "<?php\n\t\t$x = bar();\n";
let offset = content.find("bar").unwrap();
let (line_start, indent) = find_enclosing_statement_line(content, offset);
assert_eq!(line_start, 6);
assert_eq!(indent, "\t\t");
}
#[test]
fn snake_to_camel_simple() {
assert_eq!(snake_to_camel("array_filter"), "arrayFilter");
}
#[test]
fn snake_to_camel_single_word() {
assert_eq!(snake_to_camel("count"), "count");
}
#[test]
fn snake_to_camel_three_parts() {
assert_eq!(snake_to_camel("str_to_upper"), "strToUpper");
}
#[test]
fn strip_parens_wrapped_expression() {
assert_eq!(strip_outer_parens("($a + $b)"), "$a + $b");
}
#[test]
fn strip_parens_no_parens() {
assert_eq!(strip_outer_parens("$a + $b"), "$a + $b");
}
#[test]
fn strip_parens_function_call_unchanged() {
assert_eq!(strip_outer_parens("foo($x)"), "foo($x)");
}
#[test]
fn strip_parens_two_groups_unchanged() {
assert_eq!(strip_outer_parens("($a) + ($b)"), "($a) + ($b)");
}
#[test]
fn strip_parens_nested() {
assert_eq!(strip_outer_parens("(($a + $b))"), "($a + $b)");
}
#[test]
fn strip_parens_with_whitespace() {
assert_eq!(strip_outer_parens("( $a + $b )"), "$a + $b");
}
#[test]
fn extract_variable_action_offered_for_selection() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nfunction test() {\n echo $user->getName();\n}\n";
backend.update_ast(uri, content);
let line2 = " echo $user->getName();\n";
let expr_start_in_line = line2.find("$user").unwrap();
let expr_end_in_line = line2.find(';').unwrap();
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, expr_start_in_line as u32),
end: Position::new(2, expr_end_in_line as u32),
},
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 extract_action = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Extract variable") => {
Some(ca)
}
_ => None,
})
.expect("expected extract variable action");
assert_eq!(extract_action.kind, Some(CodeActionKind::REFACTOR_EXTRACT));
}
#[test]
fn extract_variable_not_offered_for_empty_selection() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nfunction test() {\n echo $user->getName();\n}\n";
backend.update_ast(uri, content);
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 9),
end: Position::new(2, 9),
},
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 extract_actions: Vec<_> = actions
.iter()
.filter(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.contains("Extract variable"),
_ => false,
})
.collect();
assert!(
extract_actions.is_empty(),
"should not offer extract variable for empty selection"
);
}
#[test]
fn extract_variable_generates_correct_edits() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nfunction test() {\n echo $user->getName();\n}\n";
backend.update_ast(uri, content);
backend
.open_files
.write()
.insert(uri.to_string(), std::sync::Arc::new(content.to_string()));
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 9),
end: Position::new(2, 25),
},
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 extract_action = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Extract variable") => {
Some(ca)
}
_ => None,
})
.expect("expected extract variable action");
assert!(
extract_action.edit.is_none(),
"Phase 1 should not compute edits"
);
assert!(
extract_action.data.is_some(),
"Phase 1 should attach resolve data"
);
let (resolved, _) = backend.resolve_code_action(extract_action.clone());
let edit = resolved
.edit
.as_ref()
.expect("expected workspace edit after resolve");
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(), 2);
let insert_edit = &file_edits[0];
assert_eq!(insert_edit.range.start, insert_edit.range.end); assert!(insert_edit.new_text.contains("$name = $user->getName();"));
assert!(insert_edit.new_text.starts_with(" ")); assert!(insert_edit.new_text.ends_with('\n'));
let replace_edit = &file_edits[1];
assert_eq!(replace_edit.new_text, "$name");
}
#[test]
fn extract_variable_deduplicates_name() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content =
"<?php\nfunction test() {\n $name = 'existing';\n echo $user->getName();\n}\n";
backend.update_ast(uri, content);
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(3, 9),
end: Position::new(3, 25),
},
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 _extract_action = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Extract variable") => {
Some(ca)
}
_ => None,
})
.expect("expected extract variable action");
}
#[test]
fn extract_variable_static_call() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nfunction test() {\n echo Carbon::now();\n}\n";
backend.update_ast(uri, content);
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 9),
end: Position::new(2, 22),
},
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 _extract_action = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Extract variable") => {
Some(ca)
}
_ => None,
})
.expect("expected extract variable action");
}
#[test]
fn extract_variable_function_call() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nfunction test() {\n echo array_filter($items, $fn);\n}\n";
backend.update_ast(uri, content);
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 9),
end: Position::new(2, 34),
},
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 _extract_action = actions
.iter()
.find_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Extract variable") => {
Some(ca)
}
_ => None,
})
.expect("expected extract variable action");
}
#[test]
fn extract_variable_whitespace_only_selection_skipped() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nfunction test() {\n echo 'hello';\n}\n";
backend.update_ast(uri, content);
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 0),
end: Position::new(2, 4),
},
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 extract_actions: Vec<_> = actions
.iter()
.filter(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.contains("Extract variable"),
_ => false,
})
.collect();
assert!(
extract_actions.is_empty(),
"should not offer extract variable for whitespace-only selection"
);
}
#[test]
fn extract_variable_not_offered_for_standalone_statement() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nfunction test() {\n $this->save($id);\n $this->log($id);\n}\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, 21),
},
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 extract_actions: Vec<_> = actions
.iter()
.filter(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.contains("Extract variable"),
_ => false,
})
.collect();
assert!(
extract_actions.is_empty(),
"should not offer extract variable for a standalone expression statement"
);
}
#[test]
fn extract_variable_not_offered_for_standalone_statement_multiline_selection() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "\
<?php
class Test {
public function dump($value): void
{
// select from here
var_dump($value);
// to here
}
}
";
backend.update_ast(uri, content);
let comment_line = " // select from here";
let vardump_line = " var_dump($value);";
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(4, comment_line.len() as u32),
end: Position::new(5, vardump_line.len() as u32),
},
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 extract_actions: Vec<_> = actions
.iter()
.filter(|a| match a {
CodeActionOrCommand::CodeAction(ca) => ca.title.contains("Extract variable"),
_ => false,
})
.collect();
assert!(
extract_actions.is_empty(),
"should not offer extract variable for standalone statement selected across lines: {:?}",
extract_actions
.iter()
.map(|a| match a {
CodeActionOrCommand::CodeAction(ca) => &ca.title,
_ => unreachable!(),
})
.collect::<Vec<_>>()
);
}
#[test]
fn assignment_rhs_full_rhs_detected() {
let content = "<?php\nfunction test() {\n $tax = $total * 0.21;\n}\n";
let start = content.find("$total * 0.21").unwrap();
let end = start + "$total * 0.21".len();
assert!(is_entire_assignment_rhs(content, start, end));
}
#[test]
fn assignment_rhs_sub_expression_not_detected() {
let content = "<?php\nfunction test() {\n $tax = $total * 0.21;\n}\n";
let start = content.find("$total").unwrap();
let end = start + "$total".len();
assert!(!is_entire_assignment_rhs(content, start, end));
}
#[test]
fn assignment_rhs_standalone_statement_not_detected() {
let content = "<?php\nfunction test() {\n echo $total * 0.21;\n}\n";
let start = content.find("$total * 0.21").unwrap();
let end = start + "$total * 0.21".len();
assert!(!is_entire_assignment_rhs(content, start, end));
}
#[test]
fn assignment_rhs_comparison_not_confused() {
let content = "<?php\nfunction test() {\n if ($x == $y) {}\n}\n";
let start = content.find("$y").unwrap();
let end = start + "$y".len();
assert!(!is_entire_assignment_rhs(content, start, end));
}
#[test]
fn is_entire_statement_true_for_full_expression() {
let content = "<?php\nfunction test() {\n $this->save($id);\n}\n";
let start = content.find("$this->save").unwrap();
let end = content.find("($id)").unwrap() + 5;
assert!(is_entire_expression_statement(content, start, end));
}
#[test]
fn is_entire_statement_false_for_sub_expression() {
let content = "<?php\nfunction test() {\n return $this->save($id);\n}\n";
let start = content.find("$this->save").unwrap();
let end = content.find("($id)").unwrap() + 5;
assert!(!is_entire_expression_statement(content, start, end));
}
#[test]
fn is_entire_statement_false_for_argument() {
let content = "<?php\nfunction test() {\n echo count($items);\n}\n";
let start = content.find("count").unwrap();
let end = content.find("($items)").unwrap() + 8;
assert!(!is_entire_expression_statement(content, start, end));
}
#[test]
fn is_entire_statement_true_for_multiline_selection_with_comment() {
let content = "<?php\nfunction test($value) {\n // comment\n var_dump($value);\n}\n";
let start = content.find("// comment").unwrap() + "// comment".len();
let end = content.find("var_dump($value);").unwrap() + "var_dump($value);".len();
assert!(is_entire_expression_statement(content, start, end));
}
#[test]
fn valid_expr_method_call() {
assert!(is_valid_expression("$this->save($id)"));
}
#[test]
fn valid_expr_property_access() {
assert!(is_valid_expression("$user->name"));
}
#[test]
fn valid_expr_variable() {
assert!(is_valid_expression("$x"));
}
#[test]
fn valid_expr_function_call() {
assert!(is_valid_expression("count($items)"));
}
#[test]
fn valid_expr_static_call() {
assert!(is_valid_expression("Carbon::now()"));
}
#[test]
fn valid_expr_new() {
assert!(is_valid_expression("new Foo($a)"));
}
#[test]
fn valid_expr_binary() {
assert!(is_valid_expression("$a + $b"));
}
#[test]
fn valid_expr_string_literal() {
assert!(is_valid_expression("'hello'"));
}
#[test]
fn valid_expr_number() {
assert!(is_valid_expression("42"));
}
#[test]
fn valid_expr_array_literal() {
assert!(is_valid_expression("[1, 2, 3]"));
}
#[test]
fn valid_expr_ternary() {
assert!(is_valid_expression("$x ? $a : $b"));
}
#[test]
fn valid_expr_parenthesized() {
assert!(is_valid_expression("($a + $b)"));
}
#[test]
fn invalid_expr_bare_method_name() {
assert!(!is_valid_expression("save"));
}
#[test]
fn invalid_expr_bare_identifier() {
assert!(!is_valid_expression("getName"));
}
#[test]
fn invalid_expr_arrow_fragment() {
assert!(!is_valid_expression("->save($id)"));
}
#[test]
fn invalid_expr_partial_call() {
assert!(!is_valid_expression("save($id"));
}
#[test]
fn invalid_expr_method_name_with_parens() {
assert!(is_valid_expression("getLabel()"));
}
#[test]
fn invalid_expr_multi_statement() {
assert!(!is_valid_expression(
"$this->generateId();\n $this->save($id)"
));
}
#[test]
fn invalid_expr_two_calls_with_semicolons() {
assert!(!is_valid_expression("foo(); bar()"));
}
#[test]
fn semicolon_in_string_not_rejected() {
assert!(is_valid_expression("'hello; world'"));
assert!(is_valid_expression("\"hello; world\""));
}
#[test]
fn trailing_semicolon_not_rejected() {
assert!(is_valid_expression("$this->save($id);"));
}
#[test]
fn invalid_expr_empty() {
assert!(!is_valid_expression(""));
}
#[test]
fn invalid_expr_whitespace() {
assert!(!is_valid_expression(" "));
}
#[test]
fn reject_bare_this_in_method_call_context() {
assert!(is_valid_expression("$this"));
}
#[test]
fn extract_variable_not_offered_for_bare_method_name() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nfunction test() {\n $this->save($id);\n}\n";
backend.update_ast(uri, content);
let save_start = content.find("save").unwrap();
let save_line = content[..save_start].matches('\n').count() as u32;
let save_col = (save_start - content[..save_start].rfind('\n').unwrap() - 1) as u32;
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(save_line, save_col),
end: Position::new(save_line, save_col + 4),
},
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 extract_actions: Vec<_> = actions
.iter()
.filter(|a| matches!(a, CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Extract variable")))
.collect();
assert!(
extract_actions.is_empty(),
"should not offer extract variable for bare method name 'save'"
);
}
#[test]
fn extract_variable_not_offered_for_method_call_fragment() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nfunction test() {\n $label = $order->getLabel();\n}\n";
backend.update_ast(uri, content);
let gl_start = content.find("getLabel()").unwrap();
let gl_line = content[..gl_start].matches('\n').count() as u32;
let gl_col = (gl_start - content[..gl_start].rfind('\n').unwrap() - 1) as u32;
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(gl_line, gl_col),
end: Position::new(gl_line, gl_col + 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 extract_actions: Vec<_> = actions
.iter()
.filter(|a| matches!(a, CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Extract variable")))
.collect();
assert!(
extract_actions.is_empty(),
"should not offer extract variable for method call fragment 'getLabel()'"
);
}
#[test]
fn find_occurrences_finds_duplicates() {
let content = "<?php echo $x->foo(); echo $x->foo(); echo $x->bar();";
let needle = "$x->foo()";
let first = content.find(needle).unwrap();
let occurrences = find_identical_occurrences(
content,
needle,
first,
first + needle.len(),
0,
content.len(),
);
assert_eq!(occurrences.len(), 1);
assert!(occurrences[0].0 > first);
}
#[test]
fn find_occurrences_none_when_unique() {
let content = "<?php echo $x->foo(); echo $x->bar();";
let needle = "$x->foo()";
let first = content.find(needle).unwrap();
let occurrences = find_identical_occurrences(
content,
needle,
first,
first + needle.len(),
0,
content.len(),
);
assert!(occurrences.is_empty());
}
#[test]
fn find_occurrences_skips_substrings() {
let content = "<?php echo $x->foo(); echo $x->fooBar();";
let needle = "$x->foo";
let first = content.find(needle).unwrap();
let occurrences = find_identical_occurrences(
content,
needle,
first,
first + needle.len(),
0,
content.len(),
);
assert!(occurrences.is_empty());
}
#[test]
fn find_occurrences_respects_scope_boundary() {
let content = "<?php\nfunction a() { echo $x->foo(); }\nfunction b() { echo $x->foo(); }\n";
let needle = "$x->foo()";
let first = content.find(needle).unwrap();
let scope_start = content.find('{').unwrap();
let scope_end = content.find('}').unwrap() + 1;
let occurrences = find_identical_occurrences(
content,
needle,
first,
first + needle.len(),
scope_start,
scope_end,
);
assert!(
occurrences.is_empty(),
"should not find occurrence in function b() when scoped to function a()"
);
}
#[test]
fn extract_variable_offers_all_occurrences_variant() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nfunction test() {\n echo $x->foo() . $x->foo();\n}\n";
backend.update_ast(uri, content);
backend
.open_files
.write()
.insert(uri.to_string(), std::sync::Arc::new(content.to_string()));
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 9),
end: Position::new(2, 19),
},
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 extract_actions: Vec<_> = actions
.iter()
.filter_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Extract variable") => {
Some(ca)
}
_ => None,
})
.collect();
assert!(
extract_actions.len() >= 2,
"expected at least 2 extract actions (single + all), got {}: {:?}",
extract_actions.len(),
extract_actions.iter().map(|a| &a.title).collect::<Vec<_>>()
);
let single_action = extract_actions
.iter()
.find(|a| a.title.contains("this occurrence"))
.expect("expected a 'this occurrence' action");
assert!(
single_action.title.contains("this occurrence"),
"single action should mention 'this occurrence', got: {}",
single_action.title
);
let all_action = extract_actions
.iter()
.find(|a| a.title.contains("all occurrences"))
.expect("expected an 'all occurrences' action");
assert!(
all_action.title.contains("all occurrences"),
"all action should mention 'all occurrences', got: {}",
all_action.title
);
assert!(
all_action.edit.is_none(),
"Phase 1 should not compute edits for all-occurrences"
);
assert!(
all_action.data.is_some(),
"Phase 1 should attach resolve data for all-occurrences"
);
let (resolved_all, _) = backend.resolve_code_action((*all_action).clone());
let all_edit = resolved_all.edit.as_ref().unwrap();
let all_changes = all_edit.changes.as_ref().unwrap();
let file_edits = all_changes.values().next().unwrap();
assert_eq!(
file_edits.len(),
3,
"expected 3 edits (1 insert + 2 replacements), got {}",
file_edits.len()
);
}
#[test]
fn extract_variable_single_occurrence_no_all_variant() {
let backend = crate::Backend::new_test();
let uri = "file:///test.php";
let content = "<?php\nfunction test() {\n echo $x->foo() . $x->bar();\n}\n";
backend.update_ast(uri, content);
let params = CodeActionParams {
text_document: TextDocumentIdentifier {
uri: uri.parse().unwrap(),
},
range: Range {
start: Position::new(2, 9),
end: Position::new(2, 19),
},
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 extract_actions: Vec<_> = actions
.iter()
.filter_map(|a| match a {
CodeActionOrCommand::CodeAction(ca) if ca.title.contains("Extract variable") => {
Some(ca)
}
_ => None,
})
.collect();
assert_eq!(extract_actions.len(), 1);
assert!(
!extract_actions[0].title.contains("this occurrence"),
"should not say 'this occurrence' when unique, got: {}",
extract_actions[0].title
);
}
}