use std::collections::HashMap;
use php_ast::{ClassBody, ClassMemberKind, NamespaceBody, Stmt, StmtKind};
use tower_lsp::lsp_types::{
CodeAction, CodeActionKind, CodeActionOrCommand, Range, TextEdit, Url, WorkspaceEdit,
};
use crate::document::ast::{ParsedDoc, SourceView};
use crate::text::word_at_position;
pub fn local_to_property_actions(
source: &str,
doc: &ParsedDoc,
range: Range,
uri: &Url,
) -> Vec<CodeActionOrCommand> {
let sv = doc.view();
let cursor = sv.byte_of_position(range.start);
let var = match word_at_position(source, range.start) {
Some(w) if w.starts_with('$') && w.len() > 1 && w != "$this" => w,
_ => return vec![],
};
let prop_name = &var[1..];
let ctx = Ctx {
var: &var,
prop_name,
uri,
};
let mut out = Vec::new();
collect_in_stmts(&doc.program().stmts, source, cursor, sv, &ctx, &mut out);
out
}
struct Ctx<'a> {
var: &'a str,
prop_name: &'a str,
uri: &'a Url,
}
fn collect_in_stmts<'a>(
stmts: &[Stmt<'a, 'a>],
source: &str,
cursor: u32,
sv: SourceView<'_>,
ctx: &Ctx<'_>,
out: &mut Vec<CodeActionOrCommand>,
) -> bool {
for stmt in stmts {
if stmt.span.end < cursor || stmt.span.start > cursor {
continue;
}
match &stmt.kind {
StmtKind::Class(c) => {
let prop_exists = c.body.members.iter().any(
|m| matches!(&m.kind, ClassMemberKind::Property(p) if p.name == ctx.prop_name),
);
if prop_exists {
return true;
}
for member in c.body.members.iter() {
let ClassMemberKind::Method(m) = &member.kind else {
continue;
};
let Some(body) = m.body else { continue };
if body.span.end < cursor || body.span.start > cursor {
continue;
}
if m.is_static {
return true;
}
if m.params.iter().any(|p| p.name == ctx.prop_name) {
return true;
}
let body_start = body.span.start as usize;
let body_end = body.span.end as usize;
let occurrences =
collect_var_occurrences(source, ctx.var, body_start, body_end);
if occurrences.is_empty() {
return true;
}
if let Some(action) = build_action(source, sv, ctx, &c.body, occurrences) {
out.push(action);
}
return true;
}
return true;
}
StmtKind::Namespace(ns) => {
if let NamespaceBody::Braced(inner) = &ns.body
&& collect_in_stmts(&inner.stmts, source, cursor, sv, ctx, out)
{
return true;
}
}
_ => {}
}
}
false
}
fn collect_var_occurrences(
source: &str,
var: &str,
body_start: usize,
body_end: usize,
) -> Vec<(usize, usize)> {
let body_text = &source[body_start..body_end];
let var_len = var.len();
let mut results = Vec::new();
let mut search_from = 0;
while let Some(pos) = body_text[search_from..].find(var) {
let abs = search_from + pos;
let before_ok = abs == 0
|| body_text
.as_bytes()
.get(abs - 1)
.is_none_or(|&b| b != b'$' && !b.is_ascii_alphanumeric() && b != b'_');
let after_ok = body_text
.as_bytes()
.get(abs + var_len)
.is_none_or(|&b| !b.is_ascii_alphanumeric() && b != b'_');
if before_ok && after_ok {
results.push((body_start + abs, body_start + abs + var_len));
}
search_from = abs + 1;
}
results
}
fn build_action(
source: &str,
sv: SourceView<'_>,
ctx: &Ctx<'_>,
class_body: &ClassBody<'_, '_>,
occurrences: Vec<(usize, usize)>,
) -> Option<CodeActionOrCommand> {
let Ctx {
var,
prop_name,
uri,
} = ctx;
let replacement = format!("$this->{prop_name}");
let indent = if let Some(first) = class_body.members.first() {
line_indent(source, first.span.start as usize)
} else {
" ".to_string()
};
let (insert_byte, prop_decl_text) = if let Some(first) = class_body.members.first() {
let line_start = source[..first.span.start as usize]
.rfind('\n')
.map_or(0, |i| i + 1);
(line_start, format!("{indent}private ${prop_name};\n"))
} else {
let after_brace = class_body.span.start as usize + 1;
let next_is_newline = source.as_bytes().get(after_brace).copied() == Some(b'\n');
if next_is_newline {
(after_brace + 1, format!("{indent}private ${prop_name};\n"))
} else {
(after_brace, format!("\n{indent}private ${prop_name};\n"))
}
};
let insert_pos = sv.position_of(insert_byte as u32);
let mut edits = Vec::new();
edits.push(TextEdit {
range: Range {
start: insert_pos,
end: insert_pos,
},
new_text: prop_decl_text,
});
for (start, end) in &occurrences {
edits.push(TextEdit {
range: Range {
start: sv.position_of(*start as u32),
end: sv.position_of(*end as u32),
},
new_text: replacement.clone(),
});
}
edits.sort_by(|a, b| {
b.range
.start
.line
.cmp(&a.range.start.line)
.then(b.range.start.character.cmp(&a.range.start.character))
});
let mut changes = HashMap::new();
changes.insert((*uri).clone(), edits);
Some(CodeActionOrCommand::CodeAction(CodeAction {
title: format!("Convert '{var}' to instance property"),
kind: Some(CodeActionKind::REFACTOR),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
..Default::default()
}))
}
fn line_indent(source: &str, pos: usize) -> String {
let line_start = source[..pos].rfind('\n').map_or(0, |i| i + 1);
source[line_start..pos]
.chars()
.take_while(|c| c.is_whitespace())
.collect()
}