use std::collections::HashMap;
use tower_lsp::lsp_types::{Position, Range, TextEdit, Url, WorkspaceEdit};
use crate::document::ast::ParsedDoc;
use crate::navigation::walk::collect_var_refs_in_scope;
use crate::text::utf16_code_units;
pub fn narrow_range_to_word(source: &str, range: Range, word: &str) -> Option<Range> {
if word.is_empty() || range.start.line != range.end.line {
return None;
}
let line = source.lines().nth(range.start.line as usize)?;
let byte_at_col = |col: u32| -> Option<usize> {
let mut u16s: u32 = 0;
if col == 0 {
return Some(0);
}
for (i, ch) in line.char_indices() {
if u16s == col {
return Some(i);
}
u16s += ch.len_utf16() as u32;
}
(u16s >= col).then_some(line.len())
};
let start_b = byte_at_col(range.start.character)?;
let end_b = byte_at_col(range.end.character)?;
let slice = line.get(start_b..end_b)?;
if slice == word {
return Some(range);
}
let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
let mut found: Option<usize> = None;
let mut search = 0usize;
while let Some(pos) = slice[search..].find(word) {
let abs = search + pos;
let before_ok = abs == 0 || !slice[..abs].chars().next_back().is_some_and(is_word_char);
let after = abs + word.len();
let after_ok =
after >= slice.len() || !slice[after..].chars().next().is_some_and(is_word_char);
if before_ok && after_ok {
found = Some(abs);
}
search = after.max(abs + 1);
}
let off = found?;
let new_start = range.start.character + utf16_code_units(&slice[..off]);
Some(Range {
start: Position {
line: range.start.line,
character: new_start,
},
end: Position {
line: range.start.line,
character: new_start + utf16_code_units(word),
},
})
}
pub fn prepare_rename(source: &str, position: Position) -> Option<Range> {
use crate::text::word_at_position;
let word = word_at_position(source, position)?;
if word.contains('\\') {
return None;
}
if is_php_keyword(&word) {
return None;
}
if is_superglobal(&word) {
return None;
}
let line = source.lines().nth(position.line as usize)?;
let col = position.character as usize;
let chars: Vec<char> = line.chars().collect();
let is_word = |c: char| c.is_alphanumeric() || c == '_';
let mut utf16_col = 0usize;
let mut char_idx = 0usize;
for (i, ch) in chars.iter().enumerate() {
let char_width = ch.len_utf16();
if utf16_col + char_width > col {
char_idx = i;
break;
}
utf16_col += char_width;
char_idx = i + 1;
}
let mut left = char_idx;
while left > 0 && is_word(chars[left - 1]) {
left -= 1;
}
let bare_word = word.trim_start_matches('$');
let start_utf16: u32 = chars[..left].iter().map(|c| c.len_utf16() as u32).sum();
let end_utf16: u32 = start_utf16 + utf16_code_units(bare_word);
Some(Range {
start: Position {
line: position.line,
character: start_utf16,
},
end: Position {
line: position.line,
character: end_utf16,
},
})
}
fn is_php_keyword(word: &str) -> bool {
matches!(
word,
"abstract"
| "and"
| "array"
| "as"
| "bool"
| "break"
| "callable"
| "case"
| "catch"
| "class"
| "clone"
| "const"
| "continue"
| "declare"
| "default"
| "die"
| "do"
| "echo"
| "else"
| "elseif"
| "empty"
| "enddeclare"
| "endfor"
| "endforeach"
| "endif"
| "endswitch"
| "endwhile"
| "enum"
| "eval"
| "exit"
| "extends"
| "false"
| "final"
| "finally"
| "float"
| "fn"
| "for"
| "foreach"
| "function"
| "global"
| "goto"
| "if"
| "implements"
| "include"
| "include_once"
| "instanceof"
| "insteadof"
| "int"
| "interface"
| "isset"
| "iterable"
| "list"
| "match"
| "mixed"
| "namespace"
| "never"
| "new"
| "null"
| "object"
| "or"
| "parent"
| "print"
| "private"
| "protected"
| "public"
| "readonly"
| "require"
| "require_once"
| "return"
| "self"
| "static"
| "string"
| "switch"
| "throw"
| "trait"
| "true"
| "try"
| "use"
| "var"
| "void"
| "while"
| "xor"
| "yield"
| "__CLASS__"
| "__DIR__"
| "__FILE__"
| "__FUNCTION__"
| "__LINE__"
| "__METHOD__"
| "__NAMESPACE__"
| "__TRAIT__"
)
}
fn is_superglobal(word: &str) -> bool {
matches!(
word,
"$_GET"
| "$_POST"
| "$_REQUEST"
| "$_FILES"
| "$_COOKIE"
| "$_SESSION"
| "$_SERVER"
| "$_ENV"
| "$GLOBALS"
| "$this"
)
}
pub fn rename_variable(
var_name: &str,
new_name: &str,
uri: &Url,
doc: &ParsedDoc,
position: Position,
) -> WorkspaceEdit {
let bare = var_name.trim_start_matches('$');
let new_bare = new_name.trim_start_matches('$');
let new_text = format!("${new_bare}");
let stmts = &doc.program().stmts;
let sv = doc.view();
let byte_off = sv.byte_of_position(position) as usize;
let mut spans = Vec::new();
collect_var_refs_in_scope(stmts, bare, byte_off, &mut spans);
let mut seen = std::collections::HashSet::new();
let mut edits: Vec<TextEdit> = spans
.into_iter()
.filter_map(|(span, _)| {
let start = sv.position_of(span.start);
let end = sv.position_of(span.end);
seen.insert((start.line, start.character))
.then_some(TextEdit {
range: Range { start, end },
new_text: new_text.clone(),
})
})
.collect();
edits.sort_by_key(|e| (e.range.start.line, e.range.start.character));
let mut changes = HashMap::new();
if !edits.is_empty() {
changes.insert(uri.clone(), edits);
}
WorkspaceEdit {
changes: if changes.is_empty() {
None
} else {
Some(changes)
},
..Default::default()
}
}