use lsp_types::{Location, Position};
use serde_json::Value;
use crate::lsp::client::uri_to_file_path;
pub fn handle(args: &Value, project_root: &str, abs_path: &str) -> String {
let action = args
.get("action")
.and_then(Value::as_str)
.unwrap_or("references");
if matches!(
action,
"replace_symbol_body" | "insert_before_symbol" | "insert_after_symbol"
) {
return handle_symbol_edit(action, args, project_root);
}
if matches!(action, "rename_preview" | "rename_apply") {
return handle_rename_refactor(action, args, project_root);
}
if matches!(action, "safe_delete_preview" | "safe_delete_apply") {
return handle_safe_delete_refactor(action, args, project_root);
}
if matches!(action, "move_preview" | "move_apply") {
return handle_move_refactor(action, args, project_root);
}
if matches!(action, "inline_preview" | "inline_apply") {
return handle_inline_refactor(action, args, project_root);
}
if action == "reformat" {
return handle_reformat_refactor(args, project_root);
}
let line = args.get("line").and_then(Value::as_u64).unwrap_or(1) as u32;
let column = args.get("column").and_then(Value::as_u64).unwrap_or(0) as u32;
let scope = args
.get("scope")
.and_then(Value::as_str)
.unwrap_or("project");
let uri = match crate::lsp::router::open_file(abs_path, project_root) {
Ok(u) => u,
Err(e) => return format!("ERROR: {e}"),
};
let position = Position::new(line.saturating_sub(1), column);
if action == "rename"
&& let Some(e) = deny_if_read_only(abs_path)
{
return e;
}
match action {
"rename" => handle_rename(args, abs_path, project_root, &uri, position),
"references" => handle_references(abs_path, project_root, &uri, position, scope),
"definition" => handle_definition(abs_path, project_root, &uri, position),
"implementations" => handle_implementations(abs_path, project_root, &uri, position, scope),
"declaration" => handle_declaration(abs_path, project_root, &uri, position),
"type_hierarchy" => handle_type_hierarchy(args, abs_path, project_root, &uri, position),
"symbols_overview" => handle_symbols_overview(abs_path, project_root, &uri),
"inspections" => handle_inspections(args, abs_path, project_root, &uri),
_ => format!(
"ERROR: Unknown action '{action}'. Available: rename, references, definition, \
implementations, declaration, type_hierarchy, symbols_overview, inspections, \
replace_symbol_body, insert_before_symbol, insert_after_symbol, \
rename_preview, rename_apply, safe_delete_preview, safe_delete_apply, \
move_preview, move_apply, inline_preview, inline_apply, reformat."
),
}
}
fn deny_if_read_only(abs_path: &str) -> Option<String> {
crate::core::pathjail::enforce_writable(std::path::Path::new(abs_path))
.err()
.map(|e| format!("ERROR: {e}"))
}
fn handle_rename(
args: &Value,
file_path: &str,
project_root: &str,
uri: &lsp_types::Uri,
position: Position,
) -> String {
let Some(new_name) = args.get("new_name").and_then(Value::as_str) else {
return "ERROR: 'new_name' parameter is required for rename.".to_string();
};
let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
backend.rename(uri, position, new_name)
});
match result {
Ok(Some(edit)) => format_workspace_edit(&edit, project_root),
Ok(None) => "No rename edits returned by language server.".to_string(),
Err(e) => format!("ERROR: {e}"),
}
}
fn handle_references(
file_path: &str,
project_root: &str,
uri: &lsp_types::Uri,
position: Position,
scope: &str,
) -> String {
let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
let locs = backend.references(uri, position, scope)?;
Ok((locs, backend.last_truncation()))
});
match result {
Ok((locations, meta)) => {
let mut out = format_locations(&locations, project_root);
out.push_str(&truncation_note(locations.len(), meta));
out
}
Err(e) => format!("ERROR: {e}"),
}
}
fn handle_definition(
file_path: &str,
project_root: &str,
uri: &lsp_types::Uri,
position: Position,
) -> String {
let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
backend.definition(uri, position)
});
match result {
Ok(resp) => {
let locations = match resp {
lsp_types::GotoDefinitionResponse::Scalar(loc) => vec![loc],
lsp_types::GotoDefinitionResponse::Array(locs) => locs,
lsp_types::GotoDefinitionResponse::Link(links) => links
.into_iter()
.map(|l| Location {
uri: l.target_uri,
range: l.target_selection_range,
})
.collect(),
};
format_locations(&locations, project_root)
}
Err(e) => format!("ERROR: {e}"),
}
}
fn handle_implementations(
file_path: &str,
project_root: &str,
uri: &lsp_types::Uri,
position: Position,
scope: &str,
) -> String {
let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
let locs = backend.implementations(uri, position, scope)?;
Ok((locs, backend.last_truncation()))
});
match result {
Ok((locations, meta)) => {
let mut out = format_locations(&locations, project_root);
out.push_str(&truncation_note(locations.len(), meta));
out
}
Err(e) => format!("ERROR: {e}"),
}
}
fn handle_declaration(
file_path: &str,
project_root: &str,
uri: &lsp_types::Uri,
position: Position,
) -> String {
let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
backend.declaration(uri, position)
});
match result {
Ok(locations) => format_locations(&locations, project_root),
Err(e) => format!("ERROR: {e}"),
}
}
use crate::lsp::backend::{
HierarchyDirection, InspectionDiag, InspectionInfo, SymbolOverviewItem, TypeHierarchyNode,
};
#[derive(Debug)]
pub(crate) struct Resolved {
pub rel_path: String,
pub start_line: usize,
pub end_line: usize,
}
pub(crate) fn apply_symbol_edit(
action: &str,
project_root: &str,
edit: &crate::lsp::backend::RangeEdit,
) -> Result<crate::lsp::backend::EditResult, String> {
use crate::lsp::backend::LspBackend;
use crate::lsp::port_discovery;
let mut backend: Box<dyn LspBackend> =
if let Some(pf) = port_discovery::read_port_file(project_root) {
if port_discovery::pid_alive(pf.pid) && port_discovery::health_ok(&pf) {
Box::new(crate::lsp::jetbrains_backend::JetBrainsHttpBackend::new(
pf.port,
pf.token,
project_root.to_string(),
pf.pid,
))
} else {
Box::new(crate::lsp::edit_apply::HeadlessBackend)
}
} else {
Box::new(crate::lsp::edit_apply::HeadlessBackend)
};
match action {
"replace_symbol_body" => backend.replace_symbol_body(edit),
"insert_before_symbol" => backend.insert_before_symbol(edit),
"insert_after_symbol" => backend.insert_after_symbol(edit),
other => Err(format!("INTERNAL: not an edit action: {other}")),
}
}
pub(crate) fn anchor_indent(content: &str, line: usize) -> String {
content
.lines()
.nth(line.saturating_sub(1))
.map(|l| l.chars().take_while(|c| *c == ' ' || *c == '\t').collect())
.unwrap_or_default()
}
pub(crate) fn reindent_first_line(text: &str, indent: &str) -> String {
if text.starts_with(' ') || text.starts_with('\t') || indent.is_empty() {
return text.to_string();
}
format!("{indent}{text}")
}
fn container_matches_ancestor(name: &str, ancestor: &str) -> bool {
if name == ancestor {
return true;
}
match name.rsplit_once(" for ") {
Some((_, target)) => target.split('<').next().unwrap_or(target).trim() == ancestor,
None => false,
}
}
pub(crate) fn resolve_name_path(name_path: &str, project_root: &str) -> Result<Resolved, String> {
use crate::core::graph_provider;
let open = graph_provider::open_or_build(project_root)
.ok_or_else(|| "NO_SYMBOL: no symbol index available".to_string())?;
let gp = &open.provider;
let segments: Vec<&str> = name_path.split('/').filter(|s| !s.is_empty()).collect();
let leaf = *segments
.last()
.ok_or_else(|| "NO_SYMBOL: empty name_path".to_string())?;
let mut leaves: Vec<_> = gp
.find_symbols(leaf, None, None)
.into_iter()
.filter(|s| s.name == leaf)
.collect();
if segments.len() >= 2 {
let ancestor = segments[segments.len() - 2];
let parents: Vec<_> = gp
.find_symbols(ancestor, None, None)
.into_iter()
.filter(|s| container_matches_ancestor(&s.name, ancestor))
.collect();
leaves.retain(|leaf_sym| {
parents.iter().any(|p| {
p.file == leaf_sym.file
&& p.start_line <= leaf_sym.start_line
&& leaf_sym.end_line <= p.end_line
})
});
}
match leaves.len() {
0 => Err(format!(
"NO_SYMBOL: '{name_path}' did not resolve to any indexed symbol"
)),
1 => Ok(Resolved {
rel_path: leaves[0].file.clone(),
start_line: leaves[0].start_line,
end_line: leaves[0].end_line,
}),
_ => {
let mut msg = format!(
"AMBIGUOUS_SYMBOL: '{name_path}' matches {} symbols; qualify it:\n",
leaves.len()
);
for s in leaves.iter().take(10) {
msg.push_str(&format!(
" {}:{} (L{}-{})\n",
s.file, s.name, s.start_line, s.end_line
));
}
Err(msg)
}
}
}
pub(crate) fn usage_range_text(
project_root: &str,
u: &crate::lsp::backend::UsageSite,
) -> Result<String, String> {
let abs = crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &u.path)
.map_err(|e| format!("CONFLICT: usage path blocked by jail: {e}"))?;
let content =
std::fs::read_to_string(&abs).map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
let s = crate::lsp::edit_apply::offset_of(&content, u.range.start_line, u.range.start_char)?;
let e = crate::lsp::edit_apply::offset_of(&content, u.range.end_line, u.range.end_char)?;
if e < s {
return Err("POSITION_OUT_OF_RANGE: end before start".to_string());
}
Ok(content[s..e].to_string())
}
pub(crate) fn plan_hash(
project_root: &str,
usages: &[crate::lsp::backend::UsageSite],
) -> Result<String, String> {
use crate::lsp::backend::TextRange0Based;
let mut rows: Vec<(String, TextRange0Based, String)> = Vec::with_capacity(usages.len());
for u in usages {
let text = usage_range_text(project_root, u)?;
rows.push((u.path.clone(), u.range, text));
}
rows.sort_by(|a, b| {
a.0.cmp(&b.0)
.then(a.1.start_line.cmp(&b.1.start_line))
.then(a.1.start_char.cmp(&b.1.start_char))
.then(a.1.end_line.cmp(&b.1.end_line))
.then(a.1.end_char.cmp(&b.1.end_char))
});
let mut canon = String::new();
for (path, r, text) in &rows {
canon.push_str(&format!(
"{path}|{}:{}-{}:{}|{text}\n",
r.start_line, r.start_char, r.end_line, r.end_char
));
}
Ok(crate::core::hasher::hash_hex(canon.as_bytes()))
}
mod ops;
#[allow(clippy::wildcard_imports)]
use ops::*;
fn parse_direction(args: &Value) -> HierarchyDirection {
match args.get("direction").and_then(Value::as_str) {
Some("subtypes") => HierarchyDirection::Subtypes,
_ => HierarchyDirection::Supertypes,
}
}
fn handle_type_hierarchy(
args: &Value,
file_path: &str,
project_root: &str,
uri: &lsp_types::Uri,
position: Position,
) -> String {
let direction = parse_direction(args);
let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
let tree = backend.type_hierarchy(uri, position, direction)?;
Ok((tree, backend.last_truncation()))
});
match result {
Ok((tree, meta)) => {
let mut out = format_type_hierarchy(&tree);
if matches!(meta, Some(m) if m.truncated) {
out.push_str("\n(truncated — depth/node cap reached)\n");
}
out
}
Err(e) => format!("ERROR: {e}"),
}
}
fn handle_symbols_overview(file_path: &str, project_root: &str, uri: &lsp_types::Uri) -> String {
let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
let items = backend.symbols_overview(uri)?;
Ok((items, backend.last_truncation()))
});
match result {
Ok((items, meta)) => {
let mut out = format_symbols_overview(&items);
out.push_str(&truncation_note(items.len(), meta));
out
}
Err(e) => format!("ERROR: {e}"),
}
}
fn handle_symbol_edit(action: &str, args: &Value, project_root: &str) -> String {
let (rel_path, start_line, end_line) = if let Some(np) =
args.get("name_path").and_then(Value::as_str)
{
match resolve_name_path(np, project_root) {
Ok(r) => (r.rel_path, r.start_line, r.end_line),
Err(e) => return format!("ERROR: {e}"),
}
} else {
let Some(path) = args.get("path").and_then(Value::as_str) else {
return "ERROR: provide 'name_path' or 'path'+'line' for symbol edits.".to_string();
};
let line = args.get("line").and_then(Value::as_u64).unwrap_or(0) as usize;
let end = args
.get("end_line")
.and_then(Value::as_u64)
.unwrap_or(line as u64) as usize;
if line == 0 {
return "ERROR: 'line' is required (1-based) when using the path fallback.".to_string();
}
(path.to_string(), line, end)
};
let abs_path =
match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
Ok(p) => p,
Err(e) => return format!("ERROR: path blocked by jail: {e}"),
};
if let Some(e) = deny_if_read_only(&abs_path) {
return e;
}
let content = match std::fs::read_to_string(&abs_path) {
Ok(c) => c,
Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
};
let expected_hash = args
.get("expected_hash")
.and_then(Value::as_str)
.map(String::from);
let (range, text) = match action {
"replace_symbol_body" => {
let Some(new_body) = args.get("new_body").and_then(Value::as_str) else {
return "ERROR: 'new_body' is required for replace_symbol_body.".to_string();
};
let end_col = content
.lines()
.nth(end_line.saturating_sub(1))
.map_or(0, str::len) as u32;
(
crate::lsp::backend::TextRange0Based {
start_line: (start_line - 1) as u32,
start_char: 0,
end_line: (end_line - 1) as u32,
end_char: end_col,
},
new_body.to_string(),
)
}
"insert_before_symbol" | "insert_after_symbol" => {
let Some(t) = args.get("text").and_then(Value::as_str) else {
return format!("ERROR: 'text' is required for {action}.");
};
let indent = anchor_indent(&content, start_line);
let final_text = format!("{}\n", reindent_first_line(t, &indent));
let insert_line = if action == "insert_before_symbol" {
(start_line - 1) as u32
} else {
end_line as u32
};
(
crate::lsp::backend::TextRange0Based {
start_line: insert_line,
start_char: 0,
end_line: insert_line,
end_char: 0,
},
final_text,
)
}
other => return format!("ERROR: INTERNAL: not an edit action: {other}"),
};
if let Some(exp) = &expected_hash {
let s =
match crate::lsp::edit_apply::offset_of(&content, range.start_line, range.start_char) {
Ok(o) => o,
Err(e) => return format!("ERROR: {e}"),
};
let e = match crate::lsp::edit_apply::offset_of(&content, range.end_line, range.end_char) {
Ok(o) => o,
Err(e) => return format!("ERROR: {e}"),
};
if e < s {
return "ERROR: POSITION_OUT_OF_RANGE: end before start".to_string();
}
let actual = crate::core::hasher::hash_hex(&content.as_bytes()[s..e]);
if *exp != actual {
return format!(
"ERROR: CONFLICT: range hash mismatch (expected={exp}, actual={actual})"
);
}
}
let edit = crate::lsp::backend::RangeEdit {
abs_path,
rel_path,
range,
text,
expected_hash,
};
match apply_symbol_edit(action, project_root, &edit) {
Ok(res) => format_edit_result(action, &res),
Err(e) => format!("ERROR: {e}"),
}
}
fn format_edit_result(action: &str, res: &crate::lsp::backend::EditResult) -> String {
if !res.applied {
return format!("{action}: not applied.");
}
let r = res.new_range;
let body = if res.diff.is_empty() {
res.edited_text.clone()
} else {
res.diff.clone()
};
format!(
"{action} applied (L{}:{}-L{}:{}):\n{}",
r.start_line + 1,
r.start_char,
r.end_line + 1,
r.end_char,
body
)
}
fn handle_inspections(
args: &Value,
file_path: &str,
project_root: &str,
uri: &lsp_types::Uri,
) -> String {
let mode = args.get("mode").and_then(Value::as_str).unwrap_or("run");
match mode {
"run" => {
let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
let diags = backend.inspections(uri)?;
Ok((diags, backend.last_truncation()))
});
match result {
Ok((diags, meta)) => {
let mut out = format_inspections(&diags);
out.push_str(&truncation_note(diags.len(), meta));
out
}
Err(e) => format!("ERROR: {e}"),
}
}
"list" => {
let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
let items = backend.list_inspections()?;
Ok((items, backend.last_truncation()))
});
match result {
Ok((items, meta)) => {
let mut out = format_inspection_list(&items);
out.push_str(&truncation_note(items.len(), meta));
out
}
Err(e) => format!("ERROR: {e}"),
}
}
other => format!("ERROR: Unknown mode '{other}' for inspections. Available: run, list."),
}
}
fn format_inspections(diags: &[InspectionDiag]) -> String {
if diags.is_empty() {
return "No inspection findings.".to_string();
}
let mut out = format!("{} finding(s):\n", diags.len());
for d in diags {
out.push_str(&format!(
" {}:{} {} {}\n",
d.path, d.line, d.severity, d.message
));
}
out
}
fn format_inspection_list(items: &[InspectionInfo]) -> String {
if items.is_empty() {
return "No inspections enabled.".to_string();
}
let mut out = format!("{} inspection(s):\n", items.len());
for i in items {
out.push_str(&format!(" {} {} {}\n", i.id, i.name, i.severity));
}
out
}
fn truncation_note(shown: usize, meta: Option<crate::lsp::backend::Truncation>) -> String {
match meta {
Some(m) if m.truncated => {
format!("\n(truncated — showing {shown} of {})\n", m.total)
}
_ => String::new(),
}
}
fn format_type_hierarchy(root: &TypeHierarchyNode) -> String {
fn walk(node: &TypeHierarchyNode, depth: usize, out: &mut String) {
let indent = " ".repeat(depth);
out.push_str(&format!(
"{indent}{} ({}:{})\n",
node.name, node.path, node.line
));
for child in &node.children {
walk(child, depth + 1, out);
}
}
let mut out = String::new();
walk(root, 0, &mut out);
out
}
fn format_symbols_overview(items: &[SymbolOverviewItem]) -> String {
if items.is_empty() {
return "No symbols found.".to_string();
}
let mut out = format!("{} symbol(s):\n", items.len());
for item in items {
out.push_str(&format!(
" {} {} (line {})\n",
item.kind, item.name, item.line
));
}
out
}
fn format_locations(locations: &[Location], project_root: &str) -> String {
if locations.is_empty() {
return "No results found.".to_string();
}
let mut out = format!("{} location(s):\n", locations.len());
for loc in locations {
let path = uri_to_file_path(&loc.uri).map_or_else(
|| loc.uri.as_str().to_string(),
|p| {
p.strip_prefix(project_root)
.map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
.unwrap_or(p)
},
);
let line = loc.range.start.line + 1;
let col = loc.range.start.character;
out.push_str(&format!(" {path}:{line}:{col}\n"));
}
out
}
fn format_workspace_edit(edit: &lsp_types::WorkspaceEdit, project_root: &str) -> String {
let mut out = String::from("Rename edits:\n");
let mut file_count = 0;
let mut edit_count = 0;
if let Some(ref changes) = edit.changes {
for (uri, edits) in changes {
let path = uri_to_file_path(uri).map_or_else(
|| uri.as_str().to_string(),
|p| {
p.strip_prefix(project_root)
.map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
.unwrap_or(p)
},
);
file_count += 1;
out.push_str(&format!(" {path}: {} edit(s)\n", edits.len()));
for e in edits {
edit_count += 1;
let line = e.range.start.line + 1;
out.push_str(&format!(" L{line}: -> \"{}\"\n", e.new_text));
}
}
}
if let Some(ref doc_changes) = edit.document_changes {
match doc_changes {
lsp_types::DocumentChanges::Edits(edits) => {
for text_edit in edits {
let path = uri_to_file_path(&text_edit.text_document.uri)
.unwrap_or_else(|| text_edit.text_document.uri.as_str().to_string());
file_count += 1;
let edits_len = text_edit.edits.len();
edit_count += edits_len;
out.push_str(&format!(" {path}: {edits_len} edit(s)\n"));
}
}
lsp_types::DocumentChanges::Operations(ops) => {
for op in ops {
if let lsp_types::DocumentChangeOperation::Edit(text_edit) = op {
let path = uri_to_file_path(&text_edit.text_document.uri)
.unwrap_or_else(|| text_edit.text_document.uri.as_str().to_string());
file_count += 1;
let edits_len = text_edit.edits.len();
edit_count += edits_len;
out.push_str(&format!(" {path}: {edits_len} edit(s)\n"));
}
}
}
}
}
out.push_str(&format!(
"\nTotal: {edit_count} edit(s) across {file_count} file(s)."
));
out
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_ops;