1use lsp_types::{Location, Position};
2use serde_json::Value;
3
4use crate::lsp::client::uri_to_file_path;
5
6pub fn handle(args: &Value, project_root: &str, abs_path: &str) -> String {
7 let action = args
8 .get("action")
9 .and_then(Value::as_str)
10 .unwrap_or("references");
11
12 if matches!(
13 action,
14 "replace_symbol_body" | "insert_before_symbol" | "insert_after_symbol"
15 ) {
16 return handle_symbol_edit(action, args, project_root);
17 }
18
19 if matches!(action, "rename_preview" | "rename_apply") {
20 return handle_rename_refactor(action, args, project_root);
21 }
22
23 if matches!(action, "safe_delete_preview" | "safe_delete_apply") {
24 return handle_safe_delete_refactor(action, args, project_root);
25 }
26
27 if matches!(action, "move_preview" | "move_apply") {
28 return handle_move_refactor(action, args, project_root);
29 }
30
31 if matches!(action, "inline_preview" | "inline_apply") {
32 return handle_inline_refactor(action, args, project_root);
33 }
34
35 if action == "reformat" {
36 return handle_reformat_refactor(args, project_root);
37 }
38
39 let line = args.get("line").and_then(Value::as_u64).unwrap_or(1) as u32;
40 let column = args.get("column").and_then(Value::as_u64).unwrap_or(0) as u32;
41 let scope = args
42 .get("scope")
43 .and_then(Value::as_str)
44 .unwrap_or("project");
45
46 let uri = match crate::lsp::router::open_file(abs_path, project_root) {
47 Ok(u) => u,
48 Err(e) => return format!("ERROR: {e}"),
49 };
50
51 let position = Position::new(line.saturating_sub(1), column);
52
53 if action == "rename"
56 && let Some(e) = deny_if_read_only(abs_path)
57 {
58 return e;
59 }
60
61 match action {
62 "rename" => handle_rename(args, abs_path, project_root, &uri, position),
63 "references" => handle_references(abs_path, project_root, &uri, position, scope),
64 "definition" => handle_definition(abs_path, project_root, &uri, position),
65 "implementations" => handle_implementations(abs_path, project_root, &uri, position, scope),
66 "declaration" => handle_declaration(abs_path, project_root, &uri, position),
67 "type_hierarchy" => handle_type_hierarchy(args, abs_path, project_root, &uri, position),
68 "symbols_overview" => handle_symbols_overview(abs_path, project_root, &uri),
69 "inspections" => handle_inspections(args, abs_path, project_root, &uri),
70 _ => format!(
71 "ERROR: Unknown action '{action}'. Available: rename, references, definition, \
72 implementations, declaration, type_hierarchy, symbols_overview, inspections, \
73 replace_symbol_body, insert_before_symbol, insert_after_symbol, \
74 rename_preview, rename_apply, safe_delete_preview, safe_delete_apply, \
75 move_preview, move_apply, inline_preview, inline_apply, reformat."
76 ),
77 }
78}
79
80fn deny_if_read_only(abs_path: &str) -> Option<String> {
86 crate::core::pathjail::enforce_writable(std::path::Path::new(abs_path))
87 .err()
88 .map(|e| format!("ERROR: {e}"))
89}
90
91fn handle_rename(
92 args: &Value,
93 file_path: &str,
94 project_root: &str,
95 uri: &lsp_types::Uri,
96 position: Position,
97) -> String {
98 let Some(new_name) = args.get("new_name").and_then(Value::as_str) else {
99 return "ERROR: 'new_name' parameter is required for rename.".to_string();
100 };
101
102 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
103 backend.rename(uri, position, new_name)
104 });
105
106 match result {
107 Ok(Some(edit)) => format_workspace_edit(&edit, project_root),
108 Ok(None) => "No rename edits returned by language server.".to_string(),
109 Err(e) => format!("ERROR: {e}"),
110 }
111}
112
113fn handle_references(
114 file_path: &str,
115 project_root: &str,
116 uri: &lsp_types::Uri,
117 position: Position,
118 scope: &str,
119) -> String {
120 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
121 let locs = backend.references(uri, position, scope)?;
122 Ok((locs, backend.last_truncation()))
123 });
124
125 match result {
126 Ok((locations, meta)) => {
127 let mut out = format_locations(&locations, project_root);
128 out.push_str(&truncation_note(locations.len(), meta));
129 out
130 }
131 Err(e) => format!("ERROR: {e}"),
132 }
133}
134
135fn handle_definition(
136 file_path: &str,
137 project_root: &str,
138 uri: &lsp_types::Uri,
139 position: Position,
140) -> String {
141 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
142 backend.definition(uri, position)
143 });
144
145 match result {
146 Ok(resp) => {
147 let locations = match resp {
148 lsp_types::GotoDefinitionResponse::Scalar(loc) => vec![loc],
149 lsp_types::GotoDefinitionResponse::Array(locs) => locs,
150 lsp_types::GotoDefinitionResponse::Link(links) => links
151 .into_iter()
152 .map(|l| Location {
153 uri: l.target_uri,
154 range: l.target_selection_range,
155 })
156 .collect(),
157 };
158 format_locations(&locations, project_root)
159 }
160 Err(e) => format!("ERROR: {e}"),
161 }
162}
163
164fn handle_implementations(
165 file_path: &str,
166 project_root: &str,
167 uri: &lsp_types::Uri,
168 position: Position,
169 scope: &str,
170) -> String {
171 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
172 let locs = backend.implementations(uri, position, scope)?;
173 Ok((locs, backend.last_truncation()))
174 });
175
176 match result {
177 Ok((locations, meta)) => {
178 let mut out = format_locations(&locations, project_root);
179 out.push_str(&truncation_note(locations.len(), meta));
180 out
181 }
182 Err(e) => format!("ERROR: {e}"),
183 }
184}
185
186fn handle_declaration(
187 file_path: &str,
188 project_root: &str,
189 uri: &lsp_types::Uri,
190 position: Position,
191) -> String {
192 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
193 backend.declaration(uri, position)
194 });
195
196 match result {
197 Ok(locations) => format_locations(&locations, project_root),
198 Err(e) => format!("ERROR: {e}"),
199 }
200}
201
202use crate::lsp::backend::{
203 HierarchyDirection, InspectionDiag, InspectionInfo, SymbolOverviewItem, TypeHierarchyNode,
204};
205
206#[derive(Debug)]
208pub(crate) struct Resolved {
209 pub rel_path: String,
210 pub start_line: usize,
211 pub end_line: usize,
212}
213
214pub(crate) fn apply_symbol_edit(
218 action: &str,
219 project_root: &str,
220 edit: &crate::lsp::backend::RangeEdit,
221) -> Result<crate::lsp::backend::EditResult, String> {
222 use crate::lsp::backend::LspBackend;
223 use crate::lsp::port_discovery;
224
225 let mut backend: Box<dyn LspBackend> =
226 if let Some(pf) = port_discovery::read_port_file(project_root) {
227 if port_discovery::pid_alive(pf.pid) && port_discovery::health_ok(&pf) {
228 Box::new(crate::lsp::jetbrains_backend::JetBrainsHttpBackend::new(
229 pf.port,
230 pf.token,
231 project_root.to_string(),
232 pf.pid,
233 ))
234 } else {
235 Box::new(crate::lsp::edit_apply::HeadlessBackend)
236 }
237 } else {
238 Box::new(crate::lsp::edit_apply::HeadlessBackend)
239 };
240
241 match action {
242 "replace_symbol_body" => backend.replace_symbol_body(edit),
243 "insert_before_symbol" => backend.insert_before_symbol(edit),
244 "insert_after_symbol" => backend.insert_after_symbol(edit),
245 other => Err(format!("INTERNAL: not an edit action: {other}")),
246 }
247}
248
249pub(crate) fn anchor_indent(content: &str, line: usize) -> String {
251 content
252 .lines()
253 .nth(line.saturating_sub(1))
254 .map(|l| l.chars().take_while(|c| *c == ' ' || *c == '\t').collect())
255 .unwrap_or_default()
256}
257
258pub(crate) fn reindent_first_line(text: &str, indent: &str) -> String {
262 if text.starts_with(' ') || text.starts_with('\t') || indent.is_empty() {
263 return text.to_string();
264 }
265 format!("{indent}{text}")
266}
267
268fn container_matches_ancestor(name: &str, ancestor: &str) -> bool {
275 if name == ancestor {
276 return true;
277 }
278 match name.rsplit_once(" for ") {
279 Some((_, target)) => target.split('<').next().unwrap_or(target).trim() == ancestor,
280 None => false,
281 }
282}
283
284pub(crate) fn resolve_name_path(name_path: &str, project_root: &str) -> Result<Resolved, String> {
288 use crate::core::graph_provider;
289 let open = graph_provider::open_or_build(project_root)
290 .ok_or_else(|| "NO_SYMBOL: no symbol index available".to_string())?;
291 let gp = &open.provider;
292
293 let segments: Vec<&str> = name_path.split('/').filter(|s| !s.is_empty()).collect();
294 let leaf = *segments
295 .last()
296 .ok_or_else(|| "NO_SYMBOL: empty name_path".to_string())?;
297
298 let mut leaves: Vec<_> = gp
300 .find_symbols(leaf, None, None)
301 .into_iter()
302 .filter(|s| s.name == leaf)
303 .collect();
304
305 if segments.len() >= 2 {
306 let ancestor = segments[segments.len() - 2];
307 let parents: Vec<_> = gp
308 .find_symbols(ancestor, None, None)
309 .into_iter()
310 .filter(|s| container_matches_ancestor(&s.name, ancestor))
311 .collect();
312 leaves.retain(|leaf_sym| {
313 parents.iter().any(|p| {
314 p.file == leaf_sym.file
315 && p.start_line <= leaf_sym.start_line
316 && leaf_sym.end_line <= p.end_line
317 })
318 });
319 }
320
321 match leaves.len() {
322 0 => Err(format!(
323 "NO_SYMBOL: '{name_path}' did not resolve to any indexed symbol"
324 )),
325 1 => Ok(Resolved {
326 rel_path: leaves[0].file.clone(),
327 start_line: leaves[0].start_line,
328 end_line: leaves[0].end_line,
329 }),
330 _ => {
331 let mut msg = format!(
332 "AMBIGUOUS_SYMBOL: '{name_path}' matches {} symbols; qualify it:\n",
333 leaves.len()
334 );
335 for s in leaves.iter().take(10) {
336 msg.push_str(&format!(
337 " {}:{} (L{}-{})\n",
338 s.file, s.name, s.start_line, s.end_line
339 ));
340 }
341 Err(msg)
342 }
343 }
344}
345
346pub(crate) fn usage_range_text(
350 project_root: &str,
351 u: &crate::lsp::backend::UsageSite,
352) -> Result<String, String> {
353 let abs = crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &u.path)
354 .map_err(|e| format!("CONFLICT: usage path blocked by jail: {e}"))?;
355 let content =
356 std::fs::read_to_string(&abs).map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
357 let s = crate::lsp::edit_apply::offset_of(&content, u.range.start_line, u.range.start_char)?;
358 let e = crate::lsp::edit_apply::offset_of(&content, u.range.end_line, u.range.end_char)?;
359 if e < s {
360 return Err("POSITION_OUT_OF_RANGE: end before start".to_string());
361 }
362 Ok(content[s..e].to_string())
363}
364
365pub(crate) fn plan_hash(
370 project_root: &str,
371 usages: &[crate::lsp::backend::UsageSite],
372) -> Result<String, String> {
373 use crate::lsp::backend::TextRange0Based;
374 let mut rows: Vec<(String, TextRange0Based, String)> = Vec::with_capacity(usages.len());
375 for u in usages {
376 let text = usage_range_text(project_root, u)?;
377 rows.push((u.path.clone(), u.range, text));
378 }
379 rows.sort_by(|a, b| {
380 a.0.cmp(&b.0)
381 .then(a.1.start_line.cmp(&b.1.start_line))
382 .then(a.1.start_char.cmp(&b.1.start_char))
383 .then(a.1.end_line.cmp(&b.1.end_line))
384 .then(a.1.end_char.cmp(&b.1.end_char))
385 });
386 let mut canon = String::new();
387 for (path, r, text) in &rows {
388 canon.push_str(&format!(
389 "{path}|{}:{}-{}:{}|{text}\n",
390 r.start_line, r.start_char, r.end_line, r.end_char
391 ));
392 }
393 Ok(crate::core::hasher::hash_hex(canon.as_bytes()))
394}
395
396fn resolve_rename_target(
399 args: &Value,
400 project_root: &str,
401) -> Result<(String, usize, usize), String> {
402 if let Some(np) = args.get("name_path").and_then(Value::as_str) {
403 let r = resolve_name_path(np, project_root)?;
404 Ok((r.rel_path, r.start_line, r.end_line))
405 } else {
406 let path = args
407 .get("path")
408 .and_then(Value::as_str)
409 .ok_or_else(|| "provide 'name_path' or 'path'+'line' for rename.".to_string())?;
410 let line = args.get("line").and_then(Value::as_u64).unwrap_or(0) as usize;
411 let end = args
412 .get("end_line")
413 .and_then(Value::as_u64)
414 .unwrap_or(line as u64) as usize;
415 if line == 0 {
416 return Err("'line' is required (1-based) when using the path fallback.".to_string());
417 }
418 Ok((path.to_string(), line, end))
419 }
420}
421
422fn live_jetbrains_backend(
426 project_root: &str,
427) -> Result<Box<dyn crate::lsp::backend::LspBackend>, String> {
428 use crate::lsp::port_discovery;
429 if let Some(pf) = port_discovery::read_port_file(project_root)
430 && port_discovery::pid_alive(pf.pid)
431 && port_discovery::health_ok(&pf)
432 {
433 return Ok(Box::new(
434 crate::lsp::jetbrains_backend::JetBrainsHttpBackend::new(
435 pf.port,
436 pf.token,
437 project_root.to_string(),
438 pf.pid,
439 ),
440 ));
441 }
442 Err("BACKEND_REQUIRED: rename requires a running JetBrains IDE \
443 (no live port file / health check failed)"
444 .to_string())
445}
446
447fn render_rename_preview(
450 backend: &mut dyn crate::lsp::backend::LspBackend,
451 project_root: &str,
452 query: &crate::lsp::backend::RenameQuery,
453 new_name: &str,
454) -> String {
455 let plan = match backend.rename_preview(query) {
456 Ok(p) => p,
457 Err(e) => return format!("ERROR: {e}"),
458 };
459 let hash = match plan_hash(project_root, &plan.usages) {
460 Ok(h) => h,
461 Err(e) => return format!("ERROR: {e}"),
462 };
463 let mut usage_files: Vec<&str> = plan.usages.iter().map(|u| u.path.as_str()).collect();
464 usage_files.sort_unstable();
465 usage_files.dedup();
466 let mut all_files: Vec<&str> = usage_files.clone();
467 all_files.push(query.rel_path.as_str());
468 all_files.sort_unstable();
469 all_files.dedup();
470 let mut out = format!(
471 "rename_preview: '{}' → '{new_name}'\n usages: {}\n files: {}\n plan_hash: {hash}\n",
472 query.rel_path,
473 plan.usages.len(),
474 all_files.len(),
475 );
476 if !plan.conflicts.is_empty() {
477 out.push_str(&format!(
478 " conflicts: {} (rename_apply blocks unless force=true)\n",
479 plan.conflicts.len()
480 ));
481 for c in &plan.conflicts {
482 out.push_str(&format!(" {}: {}\n", c.path, c.message));
483 }
484 }
485 for f in &usage_files {
486 let n = plan.usages.iter().filter(|u| u.path == **f).count();
487 out.push_str(&format!(" {f}: {n} usage(s)\n"));
488 }
489 out
490}
491
492fn render_rename_apply(
495 backend: &mut dyn crate::lsp::backend::LspBackend,
496 project_root: &str,
497 query: &crate::lsp::backend::RenameQuery,
498 new_name: &str,
499 expected_hash: &str,
500 force: bool,
501) -> String {
502 let plan = match backend.rename_preview(query) {
503 Ok(p) => p,
504 Err(e) => return format!("ERROR: {e}"),
505 };
506 let mut pre: Vec<(String, u32, String)> = Vec::with_capacity(plan.usages.len());
507 for u in &plan.usages {
508 match usage_range_text(project_root, u) {
509 Ok(t) => pre.push((u.path.clone(), u.range.start_line + 1, t)),
510 Err(e) => return format!("ERROR: {e}"),
511 }
512 }
513 let actual = match plan_hash(project_root, &plan.usages) {
514 Ok(h) => h,
515 Err(e) => return format!("ERROR: {e}"),
516 };
517 if actual != expected_hash {
518 return format!(
519 "ERROR: CONFLICT: plan_hash mismatch (source changed since preview; \
520 expected={expected_hash}, actual={actual})"
521 );
522 }
523 if !plan.conflicts.is_empty() && !force {
524 return format!(
525 "ERROR: CONFLICT: {} refactoring conflict(s); pass force=true to override",
526 plan.conflicts.len()
527 );
528 }
529
530 let apply = crate::lsp::backend::RenameApply {
531 abs_path: query.abs_path.clone(),
532 rel_path: query.rel_path.clone(),
533 target_range: query.target_range,
534 new_name: new_name.to_string(),
535 force,
536 };
537 let res = match backend.rename_apply(&apply) {
538 Ok(r) => r,
539 Err(e) => return format!("ERROR: {e}"),
540 };
541
542 for cp in &res.changed_paths {
544 match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
545 Ok(abs) => crate::core::cli_cache::invalidate(&abs),
546 Err(e) => return format!("ERROR: CONFLICT: changed path blocked by jail: {e}"),
547 }
548 }
549
550 let mut out = format!(
551 "rename_apply: '{}' → '{new_name}' applied\n changed files: {}\n usages: {}\n",
552 query.rel_path,
553 res.changed_paths.len(),
554 pre.len(),
555 );
556 for (path, line, old) in &pre {
557 out.push_str(&format!(" {path}:{line} \"{old}\" → \"{new_name}\"\n"));
558 }
559 out
560}
561
562fn handle_rename_refactor(action: &str, args: &Value, project_root: &str) -> String {
565 let Some(new_name) = args.get("new_name").and_then(Value::as_str) else {
566 return "ERROR: 'new_name' is required for rename.".to_string();
567 };
568 if action == "rename_apply" && args.get("plan_hash").and_then(Value::as_str).is_none() {
569 return "ERROR: 'plan_hash' is required for rename_apply (run rename_preview first)."
570 .to_string();
571 }
572
573 let (rel_path, start_line, end_line) = match resolve_rename_target(args, project_root) {
574 Ok(t) => t,
575 Err(e) => return format!("ERROR: {e}"),
576 };
577 let abs_path =
578 match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
579 Ok(p) => p,
580 Err(e) => return format!("ERROR: path blocked by jail: {e}"),
581 };
582 if action == "rename_apply"
584 && let Some(e) = deny_if_read_only(&abs_path)
585 {
586 return e;
587 }
588 let content = match std::fs::read_to_string(&abs_path) {
589 Ok(c) => c,
590 Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
591 };
592 let end_col = content
593 .lines()
594 .nth(end_line.saturating_sub(1))
595 .map_or(0, str::len) as u32;
596 let target_range = crate::lsp::backend::TextRange0Based {
597 start_line: (start_line - 1) as u32,
598 start_char: 0,
599 end_line: (end_line - 1) as u32,
600 end_char: end_col,
601 };
602 let search_comments = args
603 .get("search_comments")
604 .and_then(Value::as_bool)
605 .unwrap_or(false);
606 let search_text_occurrences = args
607 .get("search_text_occurrences")
608 .and_then(Value::as_bool)
609 .unwrap_or(false);
610
611 let mut backend = match live_jetbrains_backend(project_root) {
612 Ok(b) => b,
613 Err(e) => return format!("ERROR: {e}"),
614 };
615
616 let query = crate::lsp::backend::RenameQuery {
617 abs_path,
618 rel_path,
619 target_range,
620 new_name: new_name.to_string(),
621 search_comments,
622 search_text_occurrences,
623 };
624
625 match action {
626 "rename_preview" => render_rename_preview(backend.as_mut(), project_root, &query, new_name),
627 "rename_apply" => {
628 let expected = args
629 .get("plan_hash")
630 .and_then(Value::as_str)
631 .unwrap_or_default();
632 let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);
633 render_rename_apply(
634 backend.as_mut(),
635 project_root,
636 &query,
637 new_name,
638 expected,
639 force,
640 )
641 }
642 other => format!("ERROR: INTERNAL: not a rename action: {other}"),
643 }
644}
645
646fn render_safe_delete_preview(
649 backend: &mut dyn crate::lsp::backend::LspBackend,
650 project_root: &str,
651 query: &crate::lsp::backend::SafeDeleteQuery,
652) -> String {
653 let plan = match backend.safe_delete_preview(query) {
654 Ok(p) => p,
655 Err(e) => return format!("ERROR: {e}"),
656 };
657 let hash = match plan_hash(project_root, &plan.usages) {
658 Ok(h) => h,
659 Err(e) => return format!("ERROR: {e}"),
660 };
661 let mut files: Vec<&str> = plan.usages.iter().map(|u| u.path.as_str()).collect();
662 files.sort_unstable();
663 files.dedup();
664 let mut out = format!(
665 "safe_delete_preview: '{}'\n blocking usages: {}\n files: {}\n plan_hash: {hash}\n",
666 query.rel_path,
667 plan.usages.len(),
668 files.len(),
669 );
670 if !plan.conflicts.is_empty() {
671 out.push_str(&format!(
672 " conflicts: {} (safe_delete_apply blocks unless force=true)\n",
673 plan.conflicts.len()
674 ));
675 for c in &plan.conflicts {
676 out.push_str(&format!(" {}: {}\n", c.path, c.message));
677 }
678 }
679 for f in &files {
680 let n = plan.usages.iter().filter(|u| u.path == **f).count();
681 out.push_str(&format!(" {f}: {n} remaining ref(s)\n"));
682 }
683 out
684}
685
686fn render_safe_delete_apply(
690 backend: &mut dyn crate::lsp::backend::LspBackend,
691 project_root: &str,
692 query: &crate::lsp::backend::SafeDeleteQuery,
693 expected_hash: &str,
694 force: bool,
695 propagate: bool,
696) -> String {
697 let plan = match backend.safe_delete_preview(query) {
698 Ok(p) => p,
699 Err(e) => return format!("ERROR: {e}"),
700 };
701 let actual = match plan_hash(project_root, &plan.usages) {
703 Ok(h) => h,
704 Err(e) => return format!("ERROR: {e}"),
705 };
706 if actual != expected_hash {
707 return format!(
708 "ERROR: CONFLICT: plan_hash mismatch (source changed since preview; \
709 expected={expected_hash}, actual={actual})"
710 );
711 }
712 if !plan.conflicts.is_empty() && !force {
714 return format!(
715 "ERROR: CONFLICT: {} blocking reference(s) remain; pass force=true to delete anyway",
716 plan.conflicts.len()
717 );
718 }
719
720 let apply = crate::lsp::backend::SafeDeleteApply {
721 query: query.clone(),
722 force,
723 propagate,
724 };
725 let res = match backend.safe_delete_apply(&apply) {
726 Ok(r) => r,
727 Err(e) => return format!("ERROR: {e}"),
728 };
729
730 for cp in &res.changed_paths {
732 match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
733 Ok(abs) => crate::core::cli_cache::invalidate(&abs),
734 Err(e) => return format!("ERROR: CONFLICT: changed path blocked by jail: {e}"),
735 }
736 }
737
738 format!(
739 "safe_delete_apply: '{}' deleted\n changed files: {}\n",
740 query.rel_path,
741 res.changed_paths.len(),
742 )
743}
744
745fn handle_safe_delete_refactor(action: &str, args: &Value, project_root: &str) -> String {
749 if action == "safe_delete_apply" && args.get("plan_hash").and_then(Value::as_str).is_none() {
750 return "ERROR: 'plan_hash' is required for safe_delete_apply (run safe_delete_preview first)."
751 .to_string();
752 }
753 let (rel_path, start_line, end_line) = match resolve_rename_target(args, project_root) {
755 Ok(t) => t,
756 Err(e) => return format!("ERROR: {e}"),
757 };
758 let abs_path =
759 match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
760 Ok(p) => p,
761 Err(e) => return format!("ERROR: path blocked by jail: {e}"),
762 };
763 if action == "safe_delete_apply"
765 && let Some(e) = deny_if_read_only(&abs_path)
766 {
767 return e;
768 }
769 let content = match std::fs::read_to_string(&abs_path) {
770 Ok(c) => c,
771 Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
772 };
773 let end_col = content
774 .lines()
775 .nth(end_line.saturating_sub(1))
776 .map_or(0, str::len) as u32;
777 let src_range = crate::lsp::backend::TextRange0Based {
778 start_line: (start_line - 1) as u32,
779 start_char: 0,
780 end_line: (end_line - 1) as u32,
781 end_char: end_col,
782 };
783
784 let mut backend = match live_jetbrains_backend(project_root) {
785 Ok(b) => b,
786 Err(e) => return format!("ERROR: {e}"),
787 };
788
789 let query = crate::lsp::backend::SafeDeleteQuery {
790 abs_path,
791 rel_path,
792 src_range,
793 };
794
795 match action {
796 "safe_delete_preview" => render_safe_delete_preview(backend.as_mut(), project_root, &query),
797 "safe_delete_apply" => {
798 let expected = args
799 .get("plan_hash")
800 .and_then(Value::as_str)
801 .unwrap_or_default();
802 let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);
803 let propagate = args
804 .get("propagate")
805 .and_then(Value::as_bool)
806 .unwrap_or(false);
807 render_safe_delete_apply(
808 backend.as_mut(),
809 project_root,
810 &query,
811 expected,
812 force,
813 propagate,
814 )
815 }
816 other => format!("ERROR: INTERNAL: not a safe_delete action: {other}"),
817 }
818}
819
820fn resolve_move_target(
826 args: &Value,
827 project_root: &str,
828) -> Result<crate::lsp::backend::MoveTarget, String> {
829 let target_path = args.get("target_path").and_then(Value::as_str);
830 let target_parent = args.get("target_parent").and_then(Value::as_str);
831 match (target_path, target_parent) {
832 (Some(_), Some(_)) | (None, None) => {
833 Err("INVALID_TARGET: set exactly one of 'target_path' or 'target_parent'".to_string())
834 }
835 (Some(tp), None) => {
836 let abs = crate::core::path_resolve::resolve_tool_path(Some(project_root), None, tp)
837 .map_err(|e| format!("INVALID_TARGET: target_path blocked by jail: {e}"))?;
838 Ok(crate::lsp::backend::MoveTarget::Path {
839 abs_path: abs,
840 rel_path: tp.to_string(),
841 })
842 }
843 (None, Some(parent_np)) => {
844 let r = resolve_name_path(parent_np, project_root)?; let abs =
846 crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &r.rel_path)
847 .map_err(|e| {
848 format!("INVALID_TARGET: target_parent file blocked by jail: {e}")
849 })?;
850 let content =
851 std::fs::read_to_string(&abs).map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
852 let end_col = content
853 .lines()
854 .nth(r.end_line.saturating_sub(1))
855 .map_or(0, str::len) as u32;
856 Ok(crate::lsp::backend::MoveTarget::Parent {
857 abs_path: abs,
858 rel_path: r.rel_path,
859 range: crate::lsp::backend::TextRange0Based {
860 start_line: (r.start_line - 1) as u32,
861 start_char: 0,
862 end_line: (r.end_line - 1) as u32,
863 end_char: end_col,
864 },
865 })
866 }
867 }
868}
869
870fn render_move_preview(
873 backend: &mut dyn crate::lsp::backend::LspBackend,
874 project_root: &str,
875 query: &crate::lsp::backend::MoveQuery,
876) -> String {
877 let plan = match backend.move_preview(query) {
878 Ok(p) => p,
879 Err(e) => return format!("ERROR: {e}"),
880 };
881 let hash = match plan_hash(project_root, &plan.usages) {
882 Ok(h) => h,
883 Err(e) => return format!("ERROR: {e}"),
884 };
885 let target_desc = match &query.target {
886 crate::lsp::backend::MoveTarget::Path { rel_path, .. } => format!("→ {rel_path}"),
887 crate::lsp::backend::MoveTarget::Parent { rel_path, .. } => {
888 format!("→ member of {rel_path}")
889 }
890 };
891 let mut files: Vec<&str> = plan.usages.iter().map(|u| u.path.as_str()).collect();
892 files.push(query.rel_path.as_str());
893 files.sort_unstable();
894 files.dedup();
895 let mut out = format!(
896 "move_preview: '{}' {target_desc}\n usages: {}\n files: {}\n plan_hash: {hash}\n",
897 query.rel_path,
898 plan.usages.len(),
899 files.len(),
900 );
901 if !plan.conflicts.is_empty() {
902 out.push_str(&format!(
903 " conflicts: {} (move_apply blocks unless force=true)\n",
904 plan.conflicts.len()
905 ));
906 for c in &plan.conflicts {
907 out.push_str(&format!(" {}: {}\n", c.path, c.message));
908 }
909 }
910 out
911}
912
913fn render_move_apply(
917 backend: &mut dyn crate::lsp::backend::LspBackend,
918 project_root: &str,
919 query: &crate::lsp::backend::MoveQuery,
920 expected_hash: &str,
921 force: bool,
922) -> String {
923 let plan = match backend.move_preview(query) {
924 Ok(p) => p,
925 Err(e) => return format!("ERROR: {e}"),
926 };
927 let actual = match plan_hash(project_root, &plan.usages) {
928 Ok(h) => h,
929 Err(e) => return format!("ERROR: {e}"),
930 };
931 if actual != expected_hash {
932 return format!(
933 "ERROR: CONFLICT: plan_hash mismatch (source changed since preview; \
934 expected={expected_hash}, actual={actual})"
935 );
936 }
937 if !plan.conflicts.is_empty() && !force {
938 return format!(
939 "ERROR: CONFLICT: {} refactoring conflict(s); pass force=true to override",
940 plan.conflicts.len()
941 );
942 }
943
944 let apply = crate::lsp::backend::MoveApply {
945 query: query.clone(),
946 force,
947 };
948 let res = match backend.move_apply(&apply) {
949 Ok(r) => r,
950 Err(e) => return format!("ERROR: {e}"),
951 };
952
953 for cp in &res.changed_paths {
956 match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
957 Ok(abs) => crate::core::cli_cache::invalidate(&abs),
958 Err(e) => return format!("ERROR: CONFLICT: changed path blocked by jail: {e}"),
959 }
960 }
961
962 format!(
963 "move_apply: '{}' applied\n changed files: {}\n",
964 query.rel_path,
965 res.changed_paths.len(),
966 )
967}
968
969fn handle_move_refactor(action: &str, args: &Value, project_root: &str) -> String {
973 if action == "move_apply" && args.get("plan_hash").and_then(Value::as_str).is_none() {
974 return "ERROR: 'plan_hash' is required for move_apply (run move_preview first)."
975 .to_string();
976 }
977 let target = match resolve_move_target(args, project_root) {
979 Ok(t) => t,
980 Err(e) => return format!("ERROR: {e}"),
981 };
982 let (rel_path, start_line, end_line) = match resolve_rename_target(args, project_root) {
983 Ok(t) => t,
984 Err(e) => return format!("ERROR: {e}"),
985 };
986 let abs_path =
987 match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
988 Ok(p) => p,
989 Err(e) => return format!("ERROR: path blocked by jail: {e}"),
990 };
991 if action == "move_apply" {
994 let dest_abs = match &target {
995 crate::lsp::backend::MoveTarget::Path { abs_path, .. }
996 | crate::lsp::backend::MoveTarget::Parent { abs_path, .. } => abs_path.as_str(),
997 };
998 if let Some(e) = deny_if_read_only(&abs_path).or_else(|| deny_if_read_only(dest_abs)) {
999 return e;
1000 }
1001 }
1002 let content = match std::fs::read_to_string(&abs_path) {
1003 Ok(c) => c,
1004 Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
1005 };
1006 let end_col = content
1007 .lines()
1008 .nth(end_line.saturating_sub(1))
1009 .map_or(0, str::len) as u32;
1010 let src_range = crate::lsp::backend::TextRange0Based {
1011 start_line: (start_line - 1) as u32,
1012 start_char: 0,
1013 end_line: (end_line - 1) as u32,
1014 end_char: end_col,
1015 };
1016
1017 let mut backend = match live_jetbrains_backend(project_root) {
1018 Ok(b) => b,
1019 Err(e) => return format!("ERROR: {e}"),
1020 };
1021
1022 let query = crate::lsp::backend::MoveQuery {
1023 abs_path,
1024 rel_path,
1025 src_range,
1026 target,
1027 };
1028
1029 match action {
1030 "move_preview" => render_move_preview(backend.as_mut(), project_root, &query),
1031 "move_apply" => {
1032 let expected = args
1033 .get("plan_hash")
1034 .and_then(Value::as_str)
1035 .unwrap_or_default();
1036 let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);
1037 render_move_apply(backend.as_mut(), project_root, &query, expected, force)
1038 }
1039 other => format!("ERROR: INTERNAL: not a move action: {other}"),
1040 }
1041}
1042
1043fn render_inline_preview(
1046 backend: &mut dyn crate::lsp::backend::LspBackend,
1047 project_root: &str,
1048 query: &crate::lsp::backend::InlineQuery,
1049) -> String {
1050 let plan = match backend.inline_preview(query) {
1051 Ok(p) => p,
1052 Err(e) => return format!("ERROR: {e}"),
1053 };
1054 let hash = match plan_hash(project_root, &plan.usages) {
1055 Ok(h) => h,
1056 Err(e) => return format!("ERROR: {e}"),
1057 };
1058 let mut files: Vec<&str> = plan.usages.iter().map(|u| u.path.as_str()).collect();
1059 files.push(query.rel_path.as_str());
1060 files.sort_unstable();
1061 files.dedup();
1062 let mut out = format!(
1063 "inline_preview: '{}'\n usages: {}\n files: {}\n plan_hash: {hash}\n",
1064 query.rel_path,
1065 plan.usages.len(),
1066 files.len(),
1067 );
1068 if !plan.conflicts.is_empty() {
1069 out.push_str(&format!(
1070 " conflicts: {} (inline_apply blocks — no force; hard refusal → UNSUPPORTED)\n",
1071 plan.conflicts.len()
1072 ));
1073 for c in &plan.conflicts {
1074 out.push_str(&format!(" {}: {}\n", c.path, c.message));
1075 }
1076 }
1077 out
1078}
1079
1080fn render_inline_apply(
1084 backend: &mut dyn crate::lsp::backend::LspBackend,
1085 project_root: &str,
1086 query: &crate::lsp::backend::InlineQuery,
1087 expected_hash: &str,
1088) -> String {
1089 let plan = match backend.inline_preview(query) {
1090 Ok(p) => p,
1091 Err(e) => return format!("ERROR: {e}"),
1092 };
1093 let actual = match plan_hash(project_root, &plan.usages) {
1094 Ok(h) => h,
1095 Err(e) => return format!("ERROR: {e}"),
1096 };
1097 if actual != expected_hash {
1098 return format!(
1099 "ERROR: CONFLICT: plan_hash mismatch (source changed since preview; \
1100 expected={expected_hash}, actual={actual})"
1101 );
1102 }
1103 if !plan.conflicts.is_empty() {
1105 return format!(
1106 "ERROR: CONFLICT: {} inline conflict(s); inline cannot be forced",
1107 plan.conflicts.len()
1108 );
1109 }
1110
1111 let apply = crate::lsp::backend::InlineApply {
1112 query: query.clone(),
1113 };
1114 let res = match backend.inline_apply(&apply) {
1115 Ok(r) => r,
1116 Err(e) => return format!("ERROR: {e}"),
1118 };
1119
1120 for cp in &res.changed_paths {
1121 match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
1122 Ok(abs) => crate::core::cli_cache::invalidate(&abs),
1123 Err(e) => return format!("ERROR: CONFLICT: changed path blocked by jail: {e}"),
1124 }
1125 }
1126
1127 format!(
1128 "inline_apply: '{}' applied\n changed files: {}\n",
1129 query.rel_path,
1130 res.changed_paths.len(),
1131 )
1132}
1133
1134fn handle_inline_refactor(action: &str, args: &Value, project_root: &str) -> String {
1137 if action == "inline_apply" && args.get("plan_hash").and_then(Value::as_str).is_none() {
1138 return "ERROR: 'plan_hash' is required for inline_apply (run inline_preview first)."
1139 .to_string();
1140 }
1141 let (rel_path, start_line, end_line) = match resolve_rename_target(args, project_root) {
1142 Ok(t) => t,
1143 Err(e) => return format!("ERROR: {e}"),
1144 };
1145 let abs_path =
1146 match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
1147 Ok(p) => p,
1148 Err(e) => return format!("ERROR: path blocked by jail: {e}"),
1149 };
1150 if action == "inline_apply"
1152 && let Some(e) = deny_if_read_only(&abs_path)
1153 {
1154 return e;
1155 }
1156 let content = match std::fs::read_to_string(&abs_path) {
1157 Ok(c) => c,
1158 Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
1159 };
1160 let end_col = content
1161 .lines()
1162 .nth(end_line.saturating_sub(1))
1163 .map_or(0, str::len) as u32;
1164 let src_range = crate::lsp::backend::TextRange0Based {
1165 start_line: (start_line - 1) as u32,
1166 start_char: 0,
1167 end_line: (end_line - 1) as u32,
1168 end_char: end_col,
1169 };
1170
1171 let mut backend = match live_jetbrains_backend(project_root) {
1172 Ok(b) => b,
1173 Err(e) => return format!("ERROR: {e}"),
1174 };
1175
1176 let keep_definition = args
1177 .get("keep_definition")
1178 .and_then(Value::as_bool)
1179 .unwrap_or(false);
1180 let query = crate::lsp::backend::InlineQuery {
1181 abs_path,
1182 rel_path,
1183 src_range,
1184 keep_definition,
1185 };
1186
1187 match action {
1188 "inline_preview" => render_inline_preview(backend.as_mut(), project_root, &query),
1189 "inline_apply" => {
1190 let expected = args
1191 .get("plan_hash")
1192 .and_then(Value::as_str)
1193 .unwrap_or_default();
1194 render_inline_apply(backend.as_mut(), project_root, &query, expected)
1195 }
1196 other => format!("ERROR: INTERNAL: not an inline action: {other}"),
1197 }
1198}
1199
1200fn resolve_reformat_scope(
1204 args: &Value,
1205 project_root: &str,
1206) -> Result<(String, String, crate::lsp::backend::ReformatScope), String> {
1207 use crate::lsp::backend::{ReformatScope, TextRange0Based};
1208 let name_path = args.get("name_path").and_then(Value::as_str);
1209 let path = args.get("path").and_then(Value::as_str);
1210 let line = args.get("line").and_then(Value::as_u64);
1211
1212 match (name_path, path) {
1213 (Some(_), Some(_)) | (None, None) => {
1214 Err("INVALID_TARGET: set exactly one of 'name_path' or 'path' for reformat".to_string())
1215 }
1216 (Some(np), None) => {
1217 let r = resolve_name_path(np, project_root)?; let abs =
1219 crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &r.rel_path)
1220 .map_err(|e| format!("INVALID_TARGET: path blocked by jail: {e}"))?;
1221 let content =
1222 std::fs::read_to_string(&abs).map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
1223 let end_col = content
1224 .lines()
1225 .nth(r.end_line.saturating_sub(1))
1226 .map_or(0, str::len) as u32;
1227 let range = TextRange0Based {
1228 start_line: (r.start_line - 1) as u32,
1229 start_char: 0,
1230 end_line: (r.end_line - 1) as u32,
1231 end_char: end_col,
1232 };
1233 Ok((abs, r.rel_path, ReformatScope::Symbol { range }))
1234 }
1235 (None, Some(p)) => {
1236 let abs = crate::core::path_resolve::resolve_tool_path(Some(project_root), None, p)
1237 .map_err(|e| format!("INVALID_TARGET: path blocked by jail: {e}"))?;
1238 match line {
1239 None => Ok((abs, p.to_string(), ReformatScope::File)),
1240 Some(l) => {
1241 if l == 0 {
1242 return Err(
1243 "INVALID_TARGET: 'line' is 1-based (>=1) for a region reformat"
1244 .to_string(),
1245 );
1246 }
1247 let end = args.get("end_line").and_then(Value::as_u64).unwrap_or(l);
1248 let content = std::fs::read_to_string(&abs)
1249 .map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
1250 let end_col = content
1251 .lines()
1252 .nth((end as usize).saturating_sub(1))
1253 .map_or(0, str::len) as u32;
1254 let range = TextRange0Based {
1255 start_line: (l - 1) as u32,
1256 start_char: 0,
1257 end_line: (end - 1) as u32,
1258 end_char: end_col,
1259 };
1260 Ok((abs, p.to_string(), ReformatScope::Region { range }))
1261 }
1262 }
1263 }
1264 }
1265}
1266
1267fn render_reformat(
1270 backend: &mut dyn crate::lsp::backend::LspBackend,
1271 project_root: &str,
1272 query: &crate::lsp::backend::ReformatQuery,
1273) -> String {
1274 let res = match backend.reformat(query) {
1275 Ok(r) => r,
1276 Err(e) => return format!("ERROR: {e}"),
1277 };
1278 for cp in &res.changed_paths {
1279 match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
1280 Ok(abs) => crate::core::cli_cache::invalidate(&abs),
1281 Err(e) => return format!("ERROR: INVALID_TARGET: changed path blocked by jail: {e}"),
1282 }
1283 }
1284 format!(
1285 "reformat: '{}' applied\n changed files: {}\n",
1286 query.rel_path,
1287 res.changed_paths.len(),
1288 )
1289}
1290
1291fn handle_reformat_refactor(args: &Value, project_root: &str) -> String {
1292 let (abs_path, rel_path, scope) = match resolve_reformat_scope(args, project_root) {
1293 Ok(t) => t,
1294 Err(e) => return format!("ERROR: {e}"),
1295 };
1296 if let Some(e) = deny_if_read_only(&abs_path) {
1298 return e;
1299 }
1300 let mut backend = match live_jetbrains_backend(project_root) {
1301 Ok(b) => b,
1302 Err(e) => return format!("ERROR: {e}"),
1303 };
1304 let optimize_imports = args
1305 .get("optimize_imports")
1306 .and_then(Value::as_bool)
1307 .unwrap_or(false);
1308 let query = crate::lsp::backend::ReformatQuery {
1309 abs_path,
1310 rel_path,
1311 scope,
1312 optimize_imports,
1313 };
1314 render_reformat(backend.as_mut(), project_root, &query)
1315}
1316
1317fn parse_direction(args: &Value) -> HierarchyDirection {
1318 match args.get("direction").and_then(Value::as_str) {
1319 Some("subtypes") => HierarchyDirection::Subtypes,
1320 _ => HierarchyDirection::Supertypes,
1321 }
1322}
1323
1324fn handle_type_hierarchy(
1325 args: &Value,
1326 file_path: &str,
1327 project_root: &str,
1328 uri: &lsp_types::Uri,
1329 position: Position,
1330) -> String {
1331 let direction = parse_direction(args);
1332 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
1333 let tree = backend.type_hierarchy(uri, position, direction)?;
1334 Ok((tree, backend.last_truncation()))
1335 });
1336 match result {
1337 Ok((tree, meta)) => {
1338 let mut out = format_type_hierarchy(&tree);
1339 if matches!(meta, Some(m) if m.truncated) {
1340 out.push_str("\n(truncated — depth/node cap reached)\n");
1341 }
1342 out
1343 }
1344 Err(e) => format!("ERROR: {e}"),
1345 }
1346}
1347
1348fn handle_symbols_overview(file_path: &str, project_root: &str, uri: &lsp_types::Uri) -> String {
1349 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
1350 let items = backend.symbols_overview(uri)?;
1351 Ok((items, backend.last_truncation()))
1352 });
1353 match result {
1354 Ok((items, meta)) => {
1355 let mut out = format_symbols_overview(&items);
1356 out.push_str(&truncation_note(items.len(), meta));
1357 out
1358 }
1359 Err(e) => format!("ERROR: {e}"),
1360 }
1361}
1362
1363fn handle_symbol_edit(action: &str, args: &Value, project_root: &str) -> String {
1364 let (rel_path, start_line, end_line) = if let Some(np) =
1365 args.get("name_path").and_then(Value::as_str)
1366 {
1367 match resolve_name_path(np, project_root) {
1368 Ok(r) => (r.rel_path, r.start_line, r.end_line),
1369 Err(e) => return format!("ERROR: {e}"),
1370 }
1371 } else {
1372 let Some(path) = args.get("path").and_then(Value::as_str) else {
1373 return "ERROR: provide 'name_path' or 'path'+'line' for symbol edits.".to_string();
1374 };
1375 let line = args.get("line").and_then(Value::as_u64).unwrap_or(0) as usize;
1376 let end = args
1377 .get("end_line")
1378 .and_then(Value::as_u64)
1379 .unwrap_or(line as u64) as usize;
1380 if line == 0 {
1381 return "ERROR: 'line' is required (1-based) when using the path fallback.".to_string();
1382 }
1383 (path.to_string(), line, end)
1384 };
1385
1386 let abs_path =
1388 match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
1389 Ok(p) => p,
1390 Err(e) => return format!("ERROR: path blocked by jail: {e}"),
1391 };
1392 if let Some(e) = deny_if_read_only(&abs_path) {
1394 return e;
1395 }
1396
1397 let content = match std::fs::read_to_string(&abs_path) {
1398 Ok(c) => c,
1399 Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
1400 };
1401
1402 let expected_hash = args
1404 .get("expected_hash")
1405 .and_then(Value::as_str)
1406 .map(String::from);
1407 let (range, text) = match action {
1408 "replace_symbol_body" => {
1409 let Some(new_body) = args.get("new_body").and_then(Value::as_str) else {
1410 return "ERROR: 'new_body' is required for replace_symbol_body.".to_string();
1411 };
1412 let end_col = content
1413 .lines()
1414 .nth(end_line.saturating_sub(1))
1415 .map_or(0, str::len) as u32;
1416 (
1417 crate::lsp::backend::TextRange0Based {
1418 start_line: (start_line - 1) as u32,
1419 start_char: 0,
1420 end_line: (end_line - 1) as u32,
1421 end_char: end_col,
1422 },
1423 new_body.to_string(),
1424 )
1425 }
1426 "insert_before_symbol" | "insert_after_symbol" => {
1427 let Some(t) = args.get("text").and_then(Value::as_str) else {
1428 return format!("ERROR: 'text' is required for {action}.");
1429 };
1430 let indent = anchor_indent(&content, start_line);
1431 let final_text = format!("{}\n", reindent_first_line(t, &indent));
1432 let insert_line = if action == "insert_before_symbol" {
1433 (start_line - 1) as u32
1434 } else {
1435 end_line as u32
1436 };
1437 (
1438 crate::lsp::backend::TextRange0Based {
1439 start_line: insert_line,
1440 start_char: 0,
1441 end_line: insert_line,
1442 end_char: 0,
1443 },
1444 final_text,
1445 )
1446 }
1447 other => return format!("ERROR: INTERNAL: not an edit action: {other}"),
1448 };
1449
1450 if let Some(exp) = &expected_hash {
1455 let s =
1456 match crate::lsp::edit_apply::offset_of(&content, range.start_line, range.start_char) {
1457 Ok(o) => o,
1458 Err(e) => return format!("ERROR: {e}"),
1459 };
1460 let e = match crate::lsp::edit_apply::offset_of(&content, range.end_line, range.end_char) {
1461 Ok(o) => o,
1462 Err(e) => return format!("ERROR: {e}"),
1463 };
1464 if e < s {
1465 return "ERROR: POSITION_OUT_OF_RANGE: end before start".to_string();
1466 }
1467 let actual = crate::core::hasher::hash_hex(&content.as_bytes()[s..e]);
1468 if *exp != actual {
1469 return format!(
1470 "ERROR: CONFLICT: range hash mismatch (expected={exp}, actual={actual})"
1471 );
1472 }
1473 }
1474
1475 let edit = crate::lsp::backend::RangeEdit {
1476 abs_path,
1477 rel_path,
1478 range,
1479 text,
1480 expected_hash,
1481 };
1482
1483 match apply_symbol_edit(action, project_root, &edit) {
1485 Ok(res) => format_edit_result(action, &res),
1486 Err(e) => format!("ERROR: {e}"),
1487 }
1488}
1489
1490fn format_edit_result(action: &str, res: &crate::lsp::backend::EditResult) -> String {
1491 if !res.applied {
1492 return format!("{action}: not applied.");
1493 }
1494 let r = res.new_range;
1495 let body = if res.diff.is_empty() {
1496 res.edited_text.clone()
1497 } else {
1498 res.diff.clone()
1499 };
1500 format!(
1501 "{action} applied (L{}:{}-L{}:{}):\n{}",
1502 r.start_line + 1,
1503 r.start_char,
1504 r.end_line + 1,
1505 r.end_char,
1506 body
1507 )
1508}
1509
1510fn handle_inspections(
1511 args: &Value,
1512 file_path: &str,
1513 project_root: &str,
1514 uri: &lsp_types::Uri,
1515) -> String {
1516 let mode = args.get("mode").and_then(Value::as_str).unwrap_or("run");
1517 match mode {
1518 "run" => {
1519 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
1520 let diags = backend.inspections(uri)?;
1521 Ok((diags, backend.last_truncation()))
1522 });
1523 match result {
1524 Ok((diags, meta)) => {
1525 let mut out = format_inspections(&diags);
1526 out.push_str(&truncation_note(diags.len(), meta));
1527 out
1528 }
1529 Err(e) => format!("ERROR: {e}"),
1530 }
1531 }
1532 "list" => {
1533 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
1534 let items = backend.list_inspections()?;
1535 Ok((items, backend.last_truncation()))
1536 });
1537 match result {
1538 Ok((items, meta)) => {
1539 let mut out = format_inspection_list(&items);
1540 out.push_str(&truncation_note(items.len(), meta));
1541 out
1542 }
1543 Err(e) => format!("ERROR: {e}"),
1544 }
1545 }
1546 other => format!("ERROR: Unknown mode '{other}' for inspections. Available: run, list."),
1547 }
1548}
1549
1550fn format_inspections(diags: &[InspectionDiag]) -> String {
1551 if diags.is_empty() {
1552 return "No inspection findings.".to_string();
1553 }
1554 let mut out = format!("{} finding(s):\n", diags.len());
1555 for d in diags {
1556 out.push_str(&format!(
1557 " {}:{} {} {}\n",
1558 d.path, d.line, d.severity, d.message
1559 ));
1560 }
1561 out
1562}
1563
1564fn format_inspection_list(items: &[InspectionInfo]) -> String {
1565 if items.is_empty() {
1566 return "No inspections enabled.".to_string();
1567 }
1568 let mut out = format!("{} inspection(s):\n", items.len());
1569 for i in items {
1570 out.push_str(&format!(" {} {} {}\n", i.id, i.name, i.severity));
1571 }
1572 out
1573}
1574
1575fn truncation_note(shown: usize, meta: Option<crate::lsp::backend::Truncation>) -> String {
1576 match meta {
1577 Some(m) if m.truncated => {
1578 format!("\n(truncated — showing {shown} of {})\n", m.total)
1579 }
1580 _ => String::new(),
1581 }
1582}
1583
1584fn format_type_hierarchy(root: &TypeHierarchyNode) -> String {
1585 fn walk(node: &TypeHierarchyNode, depth: usize, out: &mut String) {
1586 let indent = " ".repeat(depth);
1587 out.push_str(&format!(
1588 "{indent}{} ({}:{})\n",
1589 node.name, node.path, node.line
1590 ));
1591 for child in &node.children {
1592 walk(child, depth + 1, out);
1593 }
1594 }
1595 let mut out = String::new();
1596 walk(root, 0, &mut out);
1597 out
1598}
1599
1600fn format_symbols_overview(items: &[SymbolOverviewItem]) -> String {
1601 if items.is_empty() {
1602 return "No symbols found.".to_string();
1603 }
1604 let mut out = format!("{} symbol(s):\n", items.len());
1605 for item in items {
1606 out.push_str(&format!(
1607 " {} {} (line {})\n",
1608 item.kind, item.name, item.line
1609 ));
1610 }
1611 out
1612}
1613
1614fn format_locations(locations: &[Location], project_root: &str) -> String {
1615 if locations.is_empty() {
1616 return "No results found.".to_string();
1617 }
1618
1619 let mut out = format!("{} location(s):\n", locations.len());
1620 for loc in locations {
1621 let path = uri_to_file_path(&loc.uri).map_or_else(
1622 || loc.uri.as_str().to_string(),
1623 |p| {
1624 p.strip_prefix(project_root)
1625 .map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
1626 .unwrap_or(p)
1627 },
1628 );
1629
1630 let line = loc.range.start.line + 1;
1631 let col = loc.range.start.character;
1632 out.push_str(&format!(" {path}:{line}:{col}\n"));
1633 }
1634 out
1635}
1636
1637fn format_workspace_edit(edit: &lsp_types::WorkspaceEdit, project_root: &str) -> String {
1638 let mut out = String::from("Rename edits:\n");
1639 let mut file_count = 0;
1640 let mut edit_count = 0;
1641
1642 if let Some(ref changes) = edit.changes {
1643 for (uri, edits) in changes {
1644 let path = uri_to_file_path(uri).map_or_else(
1645 || uri.as_str().to_string(),
1646 |p| {
1647 p.strip_prefix(project_root)
1648 .map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
1649 .unwrap_or(p)
1650 },
1651 );
1652
1653 file_count += 1;
1654 out.push_str(&format!(" {path}: {} edit(s)\n", edits.len()));
1655 for e in edits {
1656 edit_count += 1;
1657 let line = e.range.start.line + 1;
1658 out.push_str(&format!(" L{line}: -> \"{}\"\n", e.new_text));
1659 }
1660 }
1661 }
1662
1663 if let Some(ref doc_changes) = edit.document_changes {
1664 match doc_changes {
1665 lsp_types::DocumentChanges::Edits(edits) => {
1666 for text_edit in edits {
1667 let path = uri_to_file_path(&text_edit.text_document.uri)
1668 .unwrap_or_else(|| text_edit.text_document.uri.as_str().to_string());
1669 file_count += 1;
1670 let edits_len = text_edit.edits.len();
1671 edit_count += edits_len;
1672 out.push_str(&format!(" {path}: {edits_len} edit(s)\n"));
1673 }
1674 }
1675 lsp_types::DocumentChanges::Operations(ops) => {
1676 for op in ops {
1677 if let lsp_types::DocumentChangeOperation::Edit(text_edit) = op {
1678 let path = uri_to_file_path(&text_edit.text_document.uri)
1679 .unwrap_or_else(|| text_edit.text_document.uri.as_str().to_string());
1680 file_count += 1;
1681 let edits_len = text_edit.edits.len();
1682 edit_count += edits_len;
1683 out.push_str(&format!(" {path}: {edits_len} edit(s)\n"));
1684 }
1685 }
1686 }
1687 }
1688 }
1689
1690 out.push_str(&format!(
1691 "\nTotal: {edit_count} edit(s) across {file_count} file(s)."
1692 ));
1693 out
1694}
1695
1696#[cfg(test)]
1697mod tests {
1698 use serde_json::json;
1699
1700 #[test]
1704 fn inner_handle_uses_provided_abs_path_not_raw_args() {
1705 let args = json!({"action": "references", "path": "../escape.rs", "line": 1, "column": 0});
1706 let out = super::handle(&args, "/proj", "/proj/jailed.rs");
1707 assert!(out.contains("/proj/jailed.rs"), "abs_path not used: {out}");
1709 assert!(
1710 !out.contains("../escape.rs"),
1711 "raw path leaked to fs layer: {out}"
1712 );
1713 }
1714
1715 #[test]
1724 fn unknown_action_help_lists_declaration() {
1725 struct StubBackend;
1726 impl crate::lsp::backend::LspBackend for StubBackend {
1727 fn open_file(
1728 &mut self,
1729 _uri: &lsp_types::Uri,
1730 _language_id: &str,
1731 _text: &str,
1732 ) -> Result<(), String> {
1733 Ok(())
1734 }
1735 fn references(
1736 &mut self,
1737 _uri: &lsp_types::Uri,
1738 _position: lsp_types::Position,
1739 _scope: &str,
1740 ) -> Result<Vec<lsp_types::Location>, String> {
1741 Ok(vec![])
1742 }
1743 fn definition(
1744 &mut self,
1745 _uri: &lsp_types::Uri,
1746 _position: lsp_types::Position,
1747 ) -> Result<lsp_types::GotoDefinitionResponse, String> {
1748 Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
1749 }
1750 fn implementations(
1751 &mut self,
1752 _uri: &lsp_types::Uri,
1753 _position: lsp_types::Position,
1754 _scope: &str,
1755 ) -> Result<Vec<lsp_types::Location>, String> {
1756 Ok(vec![])
1757 }
1758 fn rename(
1759 &mut self,
1760 _uri: &lsp_types::Uri,
1761 _position: lsp_types::Position,
1762 _new_name: &str,
1763 ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
1764 Ok(None)
1765 }
1766 }
1767
1768 let dir = std::env::temp_dir().join(format!("leanctx_r1_{}", std::process::id()));
1769 std::fs::create_dir_all(&dir).unwrap();
1770 let file = dir.join("x.rs");
1771 std::fs::write(&file, "fn x() {}\n").unwrap();
1772 let root = dir.to_string_lossy().to_string();
1773 let abs = file.to_string_lossy().to_string();
1774
1775 crate::lsp::router::seed_stub_backend("rust", Box::new(StubBackend));
1776
1777 let args = json!({"action": "definitely_bogus", "path": "x.rs", "line": 1});
1778 let out = super::handle(&args, &root, &abs);
1779 assert!(
1780 out.contains("declaration"),
1781 "help text missing declaration: {out}"
1782 );
1783 assert!(
1784 out.contains("inspections"),
1785 "help text missing inspections: {out}"
1786 );
1787
1788 let _ = std::fs::remove_dir_all(&dir);
1789 }
1790
1791 #[test]
1792 fn type_hierarchy_formats_indented_tree() {
1793 use crate::lsp::backend::{
1794 HierarchyDirection, LspBackend, SymbolOverviewItem, TypeHierarchyNode,
1795 };
1796
1797 struct HierBackend;
1798 impl LspBackend for HierBackend {
1799 fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
1800 Ok(())
1801 }
1802 fn references(
1803 &mut self,
1804 _u: &lsp_types::Uri,
1805 _p: lsp_types::Position,
1806 _s: &str,
1807 ) -> Result<Vec<lsp_types::Location>, String> {
1808 Ok(vec![])
1809 }
1810 fn definition(
1811 &mut self,
1812 _u: &lsp_types::Uri,
1813 _p: lsp_types::Position,
1814 ) -> Result<lsp_types::GotoDefinitionResponse, String> {
1815 Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
1816 }
1817 fn implementations(
1818 &mut self,
1819 _u: &lsp_types::Uri,
1820 _p: lsp_types::Position,
1821 _s: &str,
1822 ) -> Result<Vec<lsp_types::Location>, String> {
1823 Ok(vec![])
1824 }
1825 fn rename(
1826 &mut self,
1827 _u: &lsp_types::Uri,
1828 _p: lsp_types::Position,
1829 _n: &str,
1830 ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
1831 Ok(None)
1832 }
1833 fn type_hierarchy(
1834 &mut self,
1835 _u: &lsp_types::Uri,
1836 _p: lsp_types::Position,
1837 dir: HierarchyDirection,
1838 ) -> Result<TypeHierarchyNode, String> {
1839 assert_eq!(dir, HierarchyDirection::Subtypes);
1840 Ok(TypeHierarchyNode {
1841 name: "Animal".into(),
1842 path: "A.kt".into(),
1843 line: 1,
1844 children: vec![TypeHierarchyNode {
1845 name: "Dog".into(),
1846 path: "A.kt".into(),
1847 line: 2,
1848 children: vec![],
1849 }],
1850 })
1851 }
1852 fn symbols_overview(
1853 &mut self,
1854 _u: &lsp_types::Uri,
1855 ) -> Result<Vec<SymbolOverviewItem>, String> {
1856 Ok(vec![SymbolOverviewItem {
1857 name: "Animal".into(),
1858 kind: "interface".into(),
1859 line: 1,
1860 }])
1861 }
1862 }
1863
1864 let tree = HierBackend
1865 .type_hierarchy(
1866 &crate::lsp::client::file_path_to_uri("/p/A.kt").unwrap(),
1867 lsp_types::Position::new(0, 0),
1868 HierarchyDirection::Subtypes,
1869 )
1870 .unwrap();
1871 let out = super::format_type_hierarchy(&tree);
1872 assert!(out.contains("Animal (A.kt:1)"), "{out}");
1873 assert!(out.contains(" Dog (A.kt:2)"), "{out}"); let items = HierBackend
1876 .symbols_overview(&crate::lsp::client::file_path_to_uri("/p/A.kt").unwrap())
1877 .unwrap();
1878 let out2 = super::format_symbols_overview(&items);
1879 assert!(out2.contains("interface Animal (line 1)"), "{out2}");
1880 }
1881
1882 #[test]
1883 fn parse_direction_defaults_to_supertypes() {
1884 use crate::lsp::backend::HierarchyDirection;
1885 assert_eq!(
1886 super::parse_direction(&json!({})),
1887 HierarchyDirection::Supertypes
1888 );
1889 assert_eq!(
1890 super::parse_direction(&json!({"direction": "subtypes"})),
1891 HierarchyDirection::Subtypes
1892 );
1893 assert_eq!(
1894 super::parse_direction(&json!({"direction": "supertypes"})),
1895 HierarchyDirection::Supertypes
1896 );
1897 }
1898
1899 #[test]
1900 fn resolve_name_path_unique_class() {
1901 let _lock = crate::core::data_dir::test_env_lock();
1902 let tmp = tempfile::tempdir().unwrap();
1903 let data = tmp.path().join("data");
1904 std::fs::create_dir_all(&data).unwrap();
1905 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
1906
1907 let proj = tmp.path().join("proj");
1908 std::fs::create_dir_all(proj.join("src")).unwrap();
1909 std::fs::write(
1910 proj.join("Cargo.toml"),
1911 "[package]\nname=\"x\"\nversion=\"0.0.0\"\n",
1912 )
1913 .unwrap();
1914 std::fs::write(
1915 proj.join("src/lib.rs"),
1916 "pub struct UniqueZqWidget { pub a: u8 }\n",
1917 )
1918 .unwrap();
1919 let root = proj.to_string_lossy().to_string();
1920
1921 let r = super::resolve_name_path("UniqueZqWidget", &root).expect("unique resolution");
1922 assert!(r.rel_path.ends_with("lib.rs"), "got: {}", r.rel_path);
1923 assert!(r.end_line >= r.start_line && r.start_line > 0);
1924
1925 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1926 }
1927
1928 #[test]
1929 fn resolve_name_path_unknown_is_no_symbol() {
1930 let _lock = crate::core::data_dir::test_env_lock();
1931 let tmp = tempfile::tempdir().unwrap();
1932 let data = tmp.path().join("data");
1933 std::fs::create_dir_all(&data).unwrap();
1934 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
1935
1936 let proj = tmp.path().join("proj");
1937 std::fs::create_dir_all(proj.join("src")).unwrap();
1938 std::fs::write(
1939 proj.join("Cargo.toml"),
1940 "[package]\nname=\"x\"\nversion=\"0.0.0\"\n",
1941 )
1942 .unwrap();
1943 std::fs::write(
1944 proj.join("src/lib.rs"),
1945 "pub struct UniqueZqWidget { pub a: u8 }\n",
1946 )
1947 .unwrap();
1948 let root = proj.to_string_lossy().to_string();
1949
1950 let err = super::resolve_name_path("ZzzNoSuchSymbol123", &root).unwrap_err();
1951 assert!(err.starts_with("NO_SYMBOL"), "got: {err}");
1952
1953 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1954 }
1955
1956 #[test]
1957 fn resolve_name_path_trait_impl_method() {
1958 let _lock = crate::core::data_dir::test_env_lock();
1959 let tmp = tempfile::tempdir().unwrap();
1960 let data = tmp.path().join("data");
1961 std::fs::create_dir_all(&data).unwrap();
1962 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
1963
1964 let proj = tmp.path().join("proj");
1965 std::fs::create_dir_all(proj.join("src")).unwrap();
1966 std::fs::write(
1967 proj.join("Cargo.toml"),
1968 "[package]\nname=\"x\"\nversion=\"0.0.0\"\n",
1969 )
1970 .unwrap();
1971 std::fs::write(
1972 proj.join("src/lib.rs"),
1973 "pub struct RenderBridge;\n\
1974 pub trait Exec { fn execute(&self); }\n\
1975 impl Exec for RenderBridge {\n\
1976 \x20 fn execute(&self) { let _ = 1; }\n\
1977 }\n",
1978 )
1979 .unwrap();
1980 let root = proj.to_string_lossy().to_string();
1981
1982 let r = super::resolve_name_path("RenderBridge/execute", &root)
1983 .expect("trait-impl method should resolve");
1984 assert!(r.rel_path.ends_with("lib.rs"), "got: {}", r.rel_path);
1985 assert!(
1988 r.start_line >= 3,
1989 "should point at impl method, got L{}",
1990 r.start_line
1991 );
1992 assert!(r.end_line >= r.start_line && r.start_line > 0);
1993
1994 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1995 }
1996
1997 #[test]
1998 fn container_matches_ancestor_cases() {
1999 use super::container_matches_ancestor as m;
2000 assert!(m("RenderBridge", "RenderBridge"));
2001 assert!(m("Exec for RenderBridge", "RenderBridge"));
2002 assert!(m("Exec for RenderBridge<Wasm>", "RenderBridge"));
2003 assert!(!m("OtherType", "RenderBridge"));
2004 assert!(!m("Exec for Other", "RenderBridge"));
2005 }
2006
2007 #[test]
2008 fn resolve_name_path_inherent_impl_method() {
2009 let _lock = crate::core::data_dir::test_env_lock();
2010 let tmp = tempfile::tempdir().unwrap();
2011 let data = tmp.path().join("data");
2012 std::fs::create_dir_all(&data).unwrap();
2013 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
2014
2015 let proj = tmp.path().join("proj");
2016 std::fs::create_dir_all(proj.join("src")).unwrap();
2017 std::fs::write(
2018 proj.join("Cargo.toml"),
2019 "[package]\nname=\"x\"\nversion=\"0.0.0\"\n",
2020 )
2021 .unwrap();
2022 std::fs::write(
2023 proj.join("src/lib.rs"),
2024 "pub struct RenderBridge;\n\
2025 impl RenderBridge {\n\
2026 \x20 pub fn run(&self) { let _ = 1; }\n\
2027 }\n",
2028 )
2029 .unwrap();
2030 let root = proj.to_string_lossy().to_string();
2031
2032 let r = super::resolve_name_path("RenderBridge/run", &root)
2033 .expect("inherent-impl method should still resolve");
2034 assert!(r.rel_path.ends_with("lib.rs"), "got: {}", r.rel_path);
2035 assert!(r.start_line >= 2 && r.end_line >= r.start_line);
2036
2037 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
2038 }
2039
2040 #[test]
2041 fn resolve_name_path_ambiguous_trait_impls() {
2042 let _lock = crate::core::data_dir::test_env_lock();
2043 let tmp = tempfile::tempdir().unwrap();
2044 let data = tmp.path().join("data");
2045 std::fs::create_dir_all(&data).unwrap();
2046 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
2047
2048 let proj = tmp.path().join("proj");
2049 std::fs::create_dir_all(proj.join("src")).unwrap();
2050 std::fs::write(
2051 proj.join("Cargo.toml"),
2052 "[package]\nname=\"x\"\nversion=\"0.0.0\"\n",
2053 )
2054 .unwrap();
2055 std::fs::write(
2056 proj.join("src/lib.rs"),
2057 "pub struct RenderBridge;\n\
2058 pub trait A { fn execute(&self); }\n\
2059 pub trait B { fn execute(&self); }\n\
2060 pub mod a;\n\
2061 pub mod b;\n",
2062 )
2063 .unwrap();
2064 std::fs::write(
2066 proj.join("src/a.rs"),
2067 "impl A for RenderBridge {\n\
2068 \x20 fn execute(&self) { let _ = 1; }\n\
2069 }\n",
2070 )
2071 .unwrap();
2072 std::fs::write(
2074 proj.join("src/b.rs"),
2075 "impl B for RenderBridge {\n\
2076 \x20 fn execute(&self) { let _ = 1; }\n\
2077 }\n",
2078 )
2079 .unwrap();
2080 let root = proj.to_string_lossy().to_string();
2081
2082 let err = super::resolve_name_path("RenderBridge/execute", &root)
2086 .expect_err("two trait impls (cross-file) with same method must be ambiguous");
2087 assert!(err.starts_with("AMBIGUOUS_SYMBOL"), "got: {err}");
2088
2089 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
2090 }
2091
2092 #[test]
2093 fn anchor_indent_reads_leading_whitespace() {
2094 let content = "class A {\n fun b() {}\n}\n";
2095 assert_eq!(super::anchor_indent(content, 2), " "); assert_eq!(super::anchor_indent(content, 1), ""); }
2098
2099 #[test]
2100 fn reindent_prefixes_first_line_only() {
2101 assert_eq!(
2102 super::reindent_first_line("fun x() {}", " "),
2103 " fun x() {}"
2104 );
2105 assert_eq!(
2107 super::reindent_first_line(" fun x()", " "),
2108 " fun x()"
2109 );
2110 }
2111
2112 #[test]
2113 fn apply_symbol_edit_headless_replaces_range() {
2114 let dir = tempfile::tempdir().unwrap();
2115 std::fs::write(dir.path().join("Foo.txt"), "aaa\nBODY\nccc\n").unwrap();
2116 let abs = dir.path().join("Foo.txt").to_string_lossy().to_string();
2117 let edit = crate::lsp::backend::RangeEdit {
2118 abs_path: abs.clone(),
2119 rel_path: "Foo.txt".into(),
2120 range: crate::lsp::backend::TextRange0Based {
2121 start_line: 1,
2122 start_char: 0,
2123 end_line: 1,
2124 end_char: 4,
2125 },
2126 text: "NEW".into(),
2127 expected_hash: None,
2128 };
2129 let res =
2131 super::apply_symbol_edit("replace_symbol_body", dir.path().to_str().unwrap(), &edit)
2132 .unwrap();
2133 assert!(res.applied);
2134 assert_eq!(std::fs::read_to_string(&abs).unwrap(), "aaa\nNEW\nccc\n");
2135 }
2136
2137 #[test]
2138 fn handle_replace_symbol_body_via_position_fallback() {
2139 let dir = tempfile::tempdir().unwrap();
2140 std::fs::write(dir.path().join("a.rs"), "fn old() {\n 1\n}\n").unwrap();
2141 let args = serde_json::json!({
2142 "action": "replace_symbol_body",
2143 "path": "a.rs",
2144 "line": 1,
2145 "end_line": 3,
2146 "new_body": "fn new() {\n 2\n}"
2147 });
2148 let out = super::handle(&args, dir.path().to_str().unwrap(), "");
2149 assert!(out.contains("replace_symbol_body applied"), "got: {out}");
2150 let after = std::fs::read_to_string(dir.path().join("a.rs")).unwrap();
2151 assert!(after.contains("fn new()"), "file: {after}");
2152 }
2153
2154 #[test]
2155 fn handle_replace_symbol_body_conflict_on_stale_hash() {
2156 let dir = tempfile::tempdir().unwrap();
2157 std::fs::write(dir.path().join("a.rs"), "fn old() {\n 1\n}\n").unwrap();
2158 let stale = serde_json::json!({
2160 "action": "replace_symbol_body",
2161 "path": "a.rs", "line": 1, "end_line": 3,
2162 "new_body": "fn new() {\n 2\n}",
2163 "expected_hash": "deadbeefnotahash"
2164 });
2165 let out = super::handle(&stale, dir.path().to_str().unwrap(), "");
2166 assert!(out.contains("CONFLICT"), "got: {out}");
2167 assert!(
2169 std::fs::read_to_string(dir.path().join("a.rs"))
2170 .unwrap()
2171 .contains("fn old()")
2172 );
2173 }
2174
2175 #[test]
2176 fn references_output_surfaces_truncation_note() {
2177 use lsp_types::Position;
2178 struct TruncBackend;
2179 impl crate::lsp::backend::LspBackend for TruncBackend {
2180 fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
2181 Ok(())
2182 }
2183 fn references(
2184 &mut self,
2185 _u: &lsp_types::Uri,
2186 _p: lsp_types::Position,
2187 _s: &str,
2188 ) -> Result<Vec<lsp_types::Location>, String> {
2189 let uri = crate::lsp::client::file_path_to_uri("/proj/a.rs").unwrap();
2190 Ok(vec![lsp_types::Location {
2191 uri,
2192 range: lsp_types::Range::default(),
2193 }])
2194 }
2195 fn definition(
2196 &mut self,
2197 _u: &lsp_types::Uri,
2198 _p: lsp_types::Position,
2199 ) -> Result<lsp_types::GotoDefinitionResponse, String> {
2200 Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
2201 }
2202 fn implementations(
2203 &mut self,
2204 _u: &lsp_types::Uri,
2205 _p: lsp_types::Position,
2206 _s: &str,
2207 ) -> Result<Vec<lsp_types::Location>, String> {
2208 Ok(vec![])
2209 }
2210 fn rename(
2211 &mut self,
2212 _u: &lsp_types::Uri,
2213 _p: lsp_types::Position,
2214 _n: &str,
2215 ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
2216 Ok(None)
2217 }
2218 fn last_truncation(&self) -> Option<crate::lsp::backend::Truncation> {
2219 Some(crate::lsp::backend::Truncation {
2220 truncated: true,
2221 total: 742,
2222 })
2223 }
2224 }
2225 crate::lsp::router::seed_stub_backend("rust", Box::new(TruncBackend));
2226 let uri = crate::lsp::client::file_path_to_uri("/proj/a.rs").unwrap();
2227 let out = super::handle_references(
2228 "/proj/a.rs",
2229 "/proj",
2230 &uri,
2231 Position {
2232 line: 0,
2233 character: 0,
2234 },
2235 "project",
2236 );
2237 assert!(
2238 out.contains("truncated"),
2239 "expected truncation note, got: {out}"
2240 );
2241 assert!(out.contains("742"), "expected total in note, got: {out}");
2242 }
2243
2244 #[test]
2245 fn inspections_run_and_list_dispatch_and_truncation() {
2246 struct InspBackend;
2247 impl crate::lsp::backend::LspBackend for InspBackend {
2248 fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
2249 Ok(())
2250 }
2251 fn references(
2252 &mut self,
2253 _u: &lsp_types::Uri,
2254 _p: lsp_types::Position,
2255 _s: &str,
2256 ) -> Result<Vec<lsp_types::Location>, String> {
2257 Ok(vec![])
2258 }
2259 fn definition(
2260 &mut self,
2261 _u: &lsp_types::Uri,
2262 _p: lsp_types::Position,
2263 ) -> Result<lsp_types::GotoDefinitionResponse, String> {
2264 Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
2265 }
2266 fn implementations(
2267 &mut self,
2268 _u: &lsp_types::Uri,
2269 _p: lsp_types::Position,
2270 _s: &str,
2271 ) -> Result<Vec<lsp_types::Location>, String> {
2272 Ok(vec![])
2273 }
2274 fn rename(
2275 &mut self,
2276 _u: &lsp_types::Uri,
2277 _p: lsp_types::Position,
2278 _n: &str,
2279 ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
2280 Ok(None)
2281 }
2282 fn inspections(
2283 &mut self,
2284 _u: &lsp_types::Uri,
2285 ) -> Result<Vec<crate::lsp::backend::InspectionDiag>, String> {
2286 Ok(vec![crate::lsp::backend::InspectionDiag {
2287 path: "A.kt".into(),
2288 line: 7,
2289 severity: "WARNING".into(),
2290 message: "unused".into(),
2291 }])
2292 }
2293 fn list_inspections(
2294 &mut self,
2295 ) -> Result<Vec<crate::lsp::backend::InspectionInfo>, String> {
2296 Ok(vec![crate::lsp::backend::InspectionInfo {
2297 id: "UnusedSymbol".into(),
2298 name: "Unused declaration".into(),
2299 severity: "WARNING".into(),
2300 }])
2301 }
2302 fn last_truncation(&self) -> Option<crate::lsp::backend::Truncation> {
2303 Some(crate::lsp::backend::Truncation {
2304 truncated: true,
2305 total: 99,
2306 })
2307 }
2308 }
2309 crate::lsp::router::seed_stub_backend("rust", Box::new(InspBackend));
2310 let uri = crate::lsp::client::file_path_to_uri("/proj/a.rs").unwrap();
2311
2312 let run_out = super::handle_inspections(
2314 &json!({"action": "inspections"}),
2315 "/proj/a.rs",
2316 "/proj",
2317 &uri,
2318 );
2319 assert!(run_out.contains("A.kt:7"), "run diag missing: {run_out}");
2320 assert!(
2321 run_out.contains("WARNING"),
2322 "run severity missing: {run_out}"
2323 );
2324 assert!(run_out.contains("unused"), "run message missing: {run_out}");
2325 assert!(
2326 run_out.contains("truncated"),
2327 "run truncation missing: {run_out}"
2328 );
2329 assert!(run_out.contains("99"), "run total missing: {run_out}");
2330
2331 let list_out = super::handle_inspections(
2333 &json!({"action": "inspections", "mode": "list"}),
2334 "/proj/a.rs",
2335 "/proj",
2336 &uri,
2337 );
2338 assert!(
2339 list_out.contains("UnusedSymbol"),
2340 "list id missing: {list_out}"
2341 );
2342 assert!(
2343 list_out.contains("Unused declaration"),
2344 "list name missing: {list_out}"
2345 );
2346
2347 let bad_out = super::handle_inspections(
2349 &json!({"action": "inspections", "mode": "bogus"}),
2350 "/proj/a.rs",
2351 "/proj",
2352 &uri,
2353 );
2354 assert!(
2355 bad_out.contains("ERROR"),
2356 "unknown mode not rejected: {bad_out}"
2357 );
2358 }
2359
2360 #[test]
2361 fn usage_range_text_reads_jailed_slice() {
2362 let dir = tempfile::tempdir().unwrap();
2363 std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2364 let root = dir.path().to_str().unwrap();
2365 let u = crate::lsp::backend::UsageSite {
2366 path: "a.rs".into(),
2367 range: crate::lsp::backend::TextRange0Based {
2368 start_line: 0,
2369 start_char: 4,
2370 end_line: 0,
2371 end_char: 7,
2372 },
2373 context: None,
2374 };
2375 assert_eq!(super::usage_range_text(root, &u).unwrap(), "foo");
2376 }
2377
2378 #[cfg(not(feature = "no-jail"))]
2382 #[test]
2383 fn usage_range_text_rejects_jail_escape() {
2384 let dir = tempfile::tempdir().unwrap();
2385 let root = dir.path().to_str().unwrap();
2386 let u = crate::lsp::backend::UsageSite {
2387 path: "../../etc/passwd".into(),
2388 range: crate::lsp::backend::TextRange0Based {
2389 start_line: 0,
2390 start_char: 0,
2391 end_line: 0,
2392 end_char: 1,
2393 },
2394 context: None,
2395 };
2396 assert!(super::usage_range_text(root, &u).is_err());
2397 }
2398
2399 #[test]
2400 fn plan_hash_is_deterministic_and_order_independent() {
2401 let dir = tempfile::tempdir().unwrap();
2402 std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2403 let root = dir.path().to_str().unwrap();
2404 let u1 = crate::lsp::backend::UsageSite {
2405 path: "a.rs".into(),
2406 range: crate::lsp::backend::TextRange0Based {
2407 start_line: 0,
2408 start_char: 4,
2409 end_line: 0,
2410 end_char: 7,
2411 },
2412 context: Some("ignored-in-hash".into()),
2413 };
2414 let u2 = crate::lsp::backend::UsageSite {
2415 path: "a.rs".into(),
2416 range: crate::lsp::backend::TextRange0Based {
2417 start_line: 1,
2418 start_char: 0,
2419 end_line: 1,
2420 end_char: 3,
2421 },
2422 context: None,
2423 };
2424 let h1 = super::plan_hash(root, &[u1.clone(), u2.clone()]).unwrap();
2425 let h2 = super::plan_hash(root, std::slice::from_ref(&u2)).unwrap(); let h3 = super::plan_hash(root, &[u2, u1]).unwrap(); assert_eq!(h1.len(), 64);
2428 assert_eq!(h1, h3, "hash must be order-independent");
2429 assert_ne!(h1, h2, "different usage set must differ");
2430 }
2431
2432 #[test]
2433 fn resolve_rename_target_position_fallback() {
2434 let (rel, sl, el) = super::resolve_rename_target(
2435 &serde_json::json!({"path": "a.rs", "line": 3, "end_line": 5}),
2436 "/proj",
2437 )
2438 .unwrap();
2439 assert_eq!(rel, "a.rs");
2440 assert_eq!((sl, el), (3, 5));
2441 }
2442
2443 #[test]
2444 fn resolve_rename_target_requires_line_in_fallback() {
2445 let err = super::resolve_rename_target(&serde_json::json!({"path": "a.rs"}), "/proj")
2446 .unwrap_err();
2447 assert!(err.contains("line"), "got: {err}");
2448 }
2449
2450 #[test]
2451 fn live_backend_absent_is_backend_required() {
2452 let err = super::live_jetbrains_backend("/nonexistent/leanctx/proj/zzz")
2454 .err()
2455 .expect("expected Err from live_jetbrains_backend");
2456 assert!(err.starts_with("BACKEND_REQUIRED"), "got: {err}");
2457 }
2458
2459 struct RenameStub {
2461 plan: crate::lsp::backend::RenamePlan,
2462 applied_with_force: std::cell::Cell<Option<bool>>,
2463 }
2464 impl crate::lsp::backend::LspBackend for RenameStub {
2465 fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
2466 Ok(())
2467 }
2468 fn references(
2469 &mut self,
2470 _u: &lsp_types::Uri,
2471 _p: lsp_types::Position,
2472 _s: &str,
2473 ) -> Result<Vec<lsp_types::Location>, String> {
2474 Ok(vec![])
2475 }
2476 fn definition(
2477 &mut self,
2478 _u: &lsp_types::Uri,
2479 _p: lsp_types::Position,
2480 ) -> Result<lsp_types::GotoDefinitionResponse, String> {
2481 Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
2482 }
2483 fn implementations(
2484 &mut self,
2485 _u: &lsp_types::Uri,
2486 _p: lsp_types::Position,
2487 _s: &str,
2488 ) -> Result<Vec<lsp_types::Location>, String> {
2489 Ok(vec![])
2490 }
2491 fn rename(
2492 &mut self,
2493 _u: &lsp_types::Uri,
2494 _p: lsp_types::Position,
2495 _n: &str,
2496 ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
2497 Ok(None)
2498 }
2499 fn rename_preview(
2500 &mut self,
2501 _q: &crate::lsp::backend::RenameQuery,
2502 ) -> Result<crate::lsp::backend::RenamePlan, String> {
2503 Ok(self.plan.clone())
2504 }
2505 fn rename_apply(
2506 &mut self,
2507 req: &crate::lsp::backend::RenameApply,
2508 ) -> Result<crate::lsp::backend::RenameResult, String> {
2509 self.applied_with_force.set(Some(req.force));
2510 Ok(crate::lsp::backend::RenameResult {
2511 applied: true,
2512 changed_paths: vec!["a.rs".into()],
2513 })
2514 }
2515 }
2516
2517 fn stub_query(abs: &str) -> crate::lsp::backend::RenameQuery {
2518 crate::lsp::backend::RenameQuery {
2519 abs_path: abs.into(),
2520 rel_path: "a.rs".into(),
2521 target_range: crate::lsp::backend::TextRange0Based {
2522 start_line: 0,
2523 start_char: 4,
2524 end_line: 0,
2525 end_char: 7,
2526 },
2527 new_name: "bar".into(),
2528 search_comments: false,
2529 search_text_occurrences: false,
2530 }
2531 }
2532
2533 #[test]
2534 fn apply_blocks_on_plan_hash_mismatch() {
2535 let dir = tempfile::tempdir().unwrap();
2536 std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2537 let root = dir.path().to_str().unwrap();
2538 let usage = crate::lsp::backend::UsageSite {
2539 path: "a.rs".into(),
2540 range: crate::lsp::backend::TextRange0Based {
2541 start_line: 0,
2542 start_char: 4,
2543 end_line: 0,
2544 end_char: 7,
2545 },
2546 context: None,
2547 };
2548 let mut be = RenameStub {
2549 plan: crate::lsp::backend::RenamePlan {
2550 usages: vec![usage],
2551 conflicts: vec![],
2552 },
2553 applied_with_force: std::cell::Cell::new(None),
2554 };
2555 let q = stub_query(&dir.path().join("a.rs").to_string_lossy());
2556 let out = super::render_rename_apply(&mut be, root, &q, "bar", "stalehash", false);
2557 assert!(out.contains("CONFLICT"), "got: {out}");
2558 assert_eq!(
2559 be.applied_with_force.get(),
2560 None,
2561 "apply must not run on hash mismatch"
2562 );
2563 }
2564
2565 #[test]
2566 fn apply_blocks_on_conflicts_without_force_and_passes_with_force() {
2567 let dir = tempfile::tempdir().unwrap();
2568 std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2569 let root = dir.path().to_str().unwrap();
2570 let usage = crate::lsp::backend::UsageSite {
2571 path: "a.rs".into(),
2572 range: crate::lsp::backend::TextRange0Based {
2573 start_line: 0,
2574 start_char: 4,
2575 end_line: 0,
2576 end_char: 7,
2577 },
2578 context: None,
2579 };
2580 let plan = crate::lsp::backend::RenamePlan {
2581 usages: vec![usage.clone()],
2582 conflicts: vec![crate::lsp::backend::Conflict {
2583 path: "a.rs".into(),
2584 range: None,
2585 message: "clash".into(),
2586 }],
2587 };
2588 let hash = super::plan_hash(root, &plan.usages).unwrap();
2589 let q = stub_query(&dir.path().join("a.rs").to_string_lossy());
2590
2591 let mut be = RenameStub {
2593 plan: plan.clone(),
2594 applied_with_force: std::cell::Cell::new(None),
2595 };
2596 let out = super::render_rename_apply(&mut be, root, &q, "bar", &hash, false);
2597 assert!(out.contains("CONFLICT"), "got: {out}");
2598 assert_eq!(be.applied_with_force.get(), None);
2599
2600 let mut be2 = RenameStub {
2602 plan,
2603 applied_with_force: std::cell::Cell::new(None),
2604 };
2605 let out2 = super::render_rename_apply(&mut be2, root, &q, "bar", &hash, true);
2606 assert!(out2.contains("applied"), "got: {out2}");
2607 assert_eq!(be2.applied_with_force.get(), Some(true));
2608 }
2609
2610 #[test]
2611 fn apply_success_emits_diff_and_evicts() {
2612 let dir = tempfile::tempdir().unwrap();
2613 std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2614 let root = dir.path().to_str().unwrap();
2615 let usage = crate::lsp::backend::UsageSite {
2616 path: "a.rs".into(),
2617 range: crate::lsp::backend::TextRange0Based {
2618 start_line: 0,
2619 start_char: 4,
2620 end_line: 0,
2621 end_char: 7,
2622 },
2623 context: None,
2624 };
2625 let plan = crate::lsp::backend::RenamePlan {
2626 usages: vec![usage],
2627 conflicts: vec![],
2628 };
2629 let hash = super::plan_hash(root, &plan.usages).unwrap();
2630 let mut be = RenameStub {
2631 plan,
2632 applied_with_force: std::cell::Cell::new(None),
2633 };
2634 let q = stub_query(&dir.path().join("a.rs").to_string_lossy());
2635 let out = super::render_rename_apply(&mut be, root, &q, "bar", &hash, false);
2636 assert!(out.contains("applied"), "got: {out}");
2637 assert!(out.contains("\"foo\" → \"bar\""), "diff missing: {out}");
2638 }
2639
2640 #[test]
2641 fn preview_renders_plan_hash_and_files() {
2642 let dir = tempfile::tempdir().unwrap();
2643 std::fs::write(dir.path().join("usage.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2644 let root = dir.path().to_str().unwrap();
2645 let usage = crate::lsp::backend::UsageSite {
2646 path: "usage.rs".into(),
2647 range: crate::lsp::backend::TextRange0Based {
2648 start_line: 0,
2649 start_char: 4,
2650 end_line: 0,
2651 end_char: 7,
2652 },
2653 context: None,
2654 };
2655 let plan = crate::lsp::backend::RenamePlan {
2656 usages: vec![usage],
2657 conflicts: vec![],
2658 };
2659 let mut be = RenameStub {
2660 plan,
2661 applied_with_force: std::cell::Cell::new(None),
2662 };
2663 let mut q = stub_query(&dir.path().join("usage.rs").to_string_lossy());
2664 q.rel_path = "decl.rs".into();
2665 let out = super::render_rename_preview(&mut be, root, &q, "bar");
2666 assert!(out.contains("plan_hash:"), "got: {out}");
2667 assert!(out.contains("usages: 1"), "got: {out}");
2668 assert!(out.contains("files: 2"), "got: {out}");
2669 assert!(out.contains("usage.rs: 1 usage"), "got: {out}");
2670 }
2671
2672 #[test]
2673 fn handle_rename_preview_without_ide_is_backend_required() {
2674 let dir = tempfile::tempdir().unwrap();
2675 std::fs::write(dir.path().join("a.rs"), "fn foo() {}\n").unwrap();
2676 let root = dir.path().to_str().unwrap();
2677 let args = serde_json::json!({
2679 "action": "rename_preview", "path": "a.rs", "line": 1, "new_name": "bar"
2680 });
2681 let out = super::handle(&args, root, "");
2682 assert!(out.contains("BACKEND_REQUIRED"), "got: {out}");
2683 }
2684
2685 #[test]
2686 fn handle_rename_apply_requires_plan_hash() {
2687 let dir = tempfile::tempdir().unwrap();
2688 std::fs::write(dir.path().join("a.rs"), "fn foo() {}\n").unwrap();
2689 let root = dir.path().to_str().unwrap();
2690 let args = serde_json::json!({
2691 "action": "rename_apply", "path": "a.rs", "line": 1, "new_name": "bar"
2692 });
2693 let out = super::handle(&args, root, "");
2694 assert!(out.contains("plan_hash"), "got: {out}");
2695 }
2696
2697 #[test]
2698 fn handle_safe_delete_preview_without_ide_is_backend_required() {
2699 let dir = tempfile::tempdir().unwrap();
2700 std::fs::write(dir.path().join("a.rs"), "fn foo() {}\n").unwrap();
2701 let root = dir.path().to_str().unwrap();
2702 let args = serde_json::json!({"action": "safe_delete_preview", "path": "a.rs", "line": 1});
2703 let out = super::handle(&args, root, "");
2704 assert!(out.contains("BACKEND_REQUIRED"), "got: {out}");
2705 }
2706
2707 #[test]
2708 fn handle_safe_delete_apply_requires_plan_hash() {
2709 let dir = tempfile::tempdir().unwrap();
2710 std::fs::write(dir.path().join("a.rs"), "fn foo() {}\n").unwrap();
2711 let root = dir.path().to_str().unwrap();
2712 let args = serde_json::json!({"action": "safe_delete_apply", "path": "a.rs", "line": 1});
2713 let out = super::handle(&args, root, "");
2714 assert!(out.contains("plan_hash"), "got: {out}");
2715 }
2716
2717 #[test]
2718 fn resolve_move_target_requires_exactly_one_field() {
2719 let dir = tempfile::tempdir().unwrap();
2720 std::fs::create_dir_all(dir.path().join("app/moved")).unwrap();
2721 let root = dir.path().to_str().unwrap();
2722
2723 let err = super::resolve_move_target(&serde_json::json!({}), root).unwrap_err();
2725 assert!(err.starts_with("INVALID_TARGET"), "got: {err}");
2726
2727 let err2 = super::resolve_move_target(
2729 &serde_json::json!({"target_path": "app/moved", "target_parent": "Other"}),
2730 root,
2731 )
2732 .unwrap_err();
2733 assert!(err2.starts_with("INVALID_TARGET"), "got: {err2}");
2734 }
2735
2736 #[cfg(not(feature = "no-jail"))]
2740 #[test]
2741 fn resolve_move_target_path_is_jailed() {
2742 let dir = tempfile::tempdir().unwrap();
2743 std::fs::create_dir_all(dir.path().join("app/moved")).unwrap();
2744 let root = dir.path().to_str().unwrap();
2745
2746 let t = super::resolve_move_target(&serde_json::json!({"target_path": "app/moved"}), root)
2748 .unwrap();
2749 match t {
2750 crate::lsp::backend::MoveTarget::Path { rel_path, .. } => {
2751 assert_eq!(rel_path, "app/moved");
2752 }
2753 other @ crate::lsp::backend::MoveTarget::Parent { .. } => {
2754 panic!("expected Path, got {other:?}")
2755 }
2756 }
2757
2758 let err =
2760 super::resolve_move_target(&serde_json::json!({"target_path": "../../etc/skel"}), root)
2761 .unwrap_err();
2762 assert!(err.starts_with("INVALID_TARGET"), "got: {err}");
2763 }
2764
2765 struct MoveStub {
2767 plan: crate::lsp::backend::RenamePlan,
2768 applied_with_force: std::cell::Cell<Option<bool>>,
2769 }
2770 impl crate::lsp::backend::LspBackend for MoveStub {
2771 fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
2772 Ok(())
2773 }
2774 fn references(
2775 &mut self,
2776 _u: &lsp_types::Uri,
2777 _p: lsp_types::Position,
2778 _s: &str,
2779 ) -> Result<Vec<lsp_types::Location>, String> {
2780 Ok(vec![])
2781 }
2782 fn definition(
2783 &mut self,
2784 _u: &lsp_types::Uri,
2785 _p: lsp_types::Position,
2786 ) -> Result<lsp_types::GotoDefinitionResponse, String> {
2787 Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
2788 }
2789 fn implementations(
2790 &mut self,
2791 _u: &lsp_types::Uri,
2792 _p: lsp_types::Position,
2793 _s: &str,
2794 ) -> Result<Vec<lsp_types::Location>, String> {
2795 Ok(vec![])
2796 }
2797 fn rename(
2798 &mut self,
2799 _u: &lsp_types::Uri,
2800 _p: lsp_types::Position,
2801 _n: &str,
2802 ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
2803 Ok(None)
2804 }
2805 fn move_preview(
2806 &mut self,
2807 _q: &crate::lsp::backend::MoveQuery,
2808 ) -> Result<crate::lsp::backend::RenamePlan, String> {
2809 Ok(self.plan.clone())
2810 }
2811 fn move_apply(
2812 &mut self,
2813 req: &crate::lsp::backend::MoveApply,
2814 ) -> Result<crate::lsp::backend::RenameResult, String> {
2815 self.applied_with_force.set(Some(req.force));
2816 Ok(crate::lsp::backend::RenameResult {
2817 applied: true,
2818 changed_paths: vec!["app/moved/Widget.kt".into()],
2819 })
2820 }
2821 }
2822
2823 fn move_query(abs: &str) -> crate::lsp::backend::MoveQuery {
2824 crate::lsp::backend::MoveQuery {
2825 abs_path: abs.into(),
2826 rel_path: "a.rs".into(),
2827 src_range: crate::lsp::backend::TextRange0Based {
2828 start_line: 0,
2829 start_char: 4,
2830 end_line: 0,
2831 end_char: 7,
2832 },
2833 target: crate::lsp::backend::MoveTarget::Path {
2834 abs_path: "/p/app/moved".into(),
2835 rel_path: "app/moved".into(),
2836 },
2837 }
2838 }
2839
2840 #[test]
2841 fn move_apply_gates_then_evicts() {
2842 let dir = tempfile::tempdir().unwrap();
2843 std::fs::create_dir_all(dir.path().join("app/moved")).unwrap();
2844 std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2845 std::fs::write(dir.path().join("app/moved/Widget.kt"), "// moved\n").unwrap();
2846 let root = dir.path().to_str().unwrap();
2847 let usage = crate::lsp::backend::UsageSite {
2848 path: "a.rs".into(),
2849 range: crate::lsp::backend::TextRange0Based {
2850 start_line: 0,
2851 start_char: 4,
2852 end_line: 0,
2853 end_char: 7,
2854 },
2855 context: None,
2856 };
2857 let plan = crate::lsp::backend::RenamePlan {
2858 usages: vec![usage],
2859 conflicts: vec![],
2860 };
2861 let hash = super::plan_hash(root, &plan.usages).unwrap();
2862 let q = move_query(&dir.path().join("a.rs").to_string_lossy());
2863
2864 let mut be = MoveStub {
2866 plan: plan.clone(),
2867 applied_with_force: std::cell::Cell::new(None),
2868 };
2869 let out = super::render_move_apply(&mut be, root, &q, "stalehash", false);
2870 assert!(out.contains("CONFLICT"), "got: {out}");
2871 assert_eq!(be.applied_with_force.get(), None);
2872
2873 let mut be2 = MoveStub {
2875 plan,
2876 applied_with_force: std::cell::Cell::new(None),
2877 };
2878 let out2 = super::render_move_apply(&mut be2, root, &q, &hash, true);
2879 assert!(out2.contains("applied"), "got: {out2}");
2880 assert_eq!(be2.applied_with_force.get(), Some(true));
2881 }
2882
2883 #[cfg(not(feature = "no-jail"))]
2885 #[test]
2886 fn move_apply_rejects_out_of_jail_changed_path() {
2887 let dir = tempfile::tempdir().unwrap();
2888 std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
2889 let root = dir.path().to_str().unwrap();
2890 let usage = crate::lsp::backend::UsageSite {
2891 path: "a.rs".into(),
2892 range: crate::lsp::backend::TextRange0Based {
2893 start_line: 0,
2894 start_char: 4,
2895 end_line: 0,
2896 end_char: 7,
2897 },
2898 context: None,
2899 };
2900 struct EscapeStub {
2902 plan: crate::lsp::backend::RenamePlan,
2903 }
2904 impl crate::lsp::backend::LspBackend for EscapeStub {
2905 fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
2906 Ok(())
2907 }
2908 fn references(
2909 &mut self,
2910 _u: &lsp_types::Uri,
2911 _p: lsp_types::Position,
2912 _s: &str,
2913 ) -> Result<Vec<lsp_types::Location>, String> {
2914 Ok(vec![])
2915 }
2916 fn definition(
2917 &mut self,
2918 _u: &lsp_types::Uri,
2919 _p: lsp_types::Position,
2920 ) -> Result<lsp_types::GotoDefinitionResponse, String> {
2921 Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
2922 }
2923 fn implementations(
2924 &mut self,
2925 _u: &lsp_types::Uri,
2926 _p: lsp_types::Position,
2927 _s: &str,
2928 ) -> Result<Vec<lsp_types::Location>, String> {
2929 Ok(vec![])
2930 }
2931 fn rename(
2932 &mut self,
2933 _u: &lsp_types::Uri,
2934 _p: lsp_types::Position,
2935 _n: &str,
2936 ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
2937 Ok(None)
2938 }
2939 fn move_preview(
2940 &mut self,
2941 _q: &crate::lsp::backend::MoveQuery,
2942 ) -> Result<crate::lsp::backend::RenamePlan, String> {
2943 Ok(self.plan.clone())
2944 }
2945 fn move_apply(
2946 &mut self,
2947 _r: &crate::lsp::backend::MoveApply,
2948 ) -> Result<crate::lsp::backend::RenameResult, String> {
2949 Ok(crate::lsp::backend::RenameResult {
2950 applied: true,
2951 changed_paths: vec!["../../etc/passwd".into()],
2952 })
2953 }
2954 }
2955 let plan = crate::lsp::backend::RenamePlan {
2956 usages: vec![usage],
2957 conflicts: vec![],
2958 };
2959 let hash = super::plan_hash(root, &plan.usages).unwrap();
2960 let mut be = EscapeStub { plan };
2961 let q = move_query(&dir.path().join("a.rs").to_string_lossy());
2962 let out = super::render_move_apply(&mut be, root, &q, &hash, false);
2963 assert!(out.contains("jail"), "expected jail rejection, got: {out}");
2964 }
2965
2966 #[test]
2967 fn handle_move_preview_invalid_target_before_backend() {
2968 let dir = tempfile::tempdir().unwrap();
2969 std::fs::write(dir.path().join("a.rs"), "fn foo() {}\n").unwrap();
2970 let root = dir.path().to_str().unwrap();
2971 let args = serde_json::json!({"action": "move_preview", "path": "a.rs", "line": 1});
2973 let out = super::handle(&args, root, "");
2974 assert!(out.contains("INVALID_TARGET"), "got: {out}");
2975 assert!(
2976 !out.contains("BACKEND_REQUIRED"),
2977 "target gate must precede backend gate: {out}"
2978 );
2979 }
2980
2981 #[test]
2982 fn handle_move_apply_requires_plan_hash() {
2983 let dir = tempfile::tempdir().unwrap();
2984 std::fs::create_dir_all(dir.path().join("x")).unwrap();
2985 std::fs::write(dir.path().join("a.rs"), "fn foo() {}\n").unwrap();
2986 let root = dir.path().to_str().unwrap();
2987 let args = serde_json::json!({"action": "move_apply", "path": "a.rs", "line": 1, "target_path": "x"});
2988 let out = super::handle(&args, root, "");
2989 assert!(out.contains("plan_hash"), "got: {out}");
2990 }
2991
2992 #[test]
2993 fn unknown_action_help_lists_rename_actions() {
2994 let args = serde_json::json!({"action": "rename_preview", "path": "a.rs", "line": 1});
2997 let out = super::handle(&args, "/proj", "");
2998 assert!(out.contains("new_name"), "got: {out}");
2999 }
3000
3001 struct SafeDeleteStub {
3003 plan: crate::lsp::backend::RenamePlan,
3004 applied: std::cell::Cell<Option<(bool, bool)>>, }
3006 impl crate::lsp::backend::LspBackend for SafeDeleteStub {
3007 fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
3008 Ok(())
3009 }
3010 fn references(
3011 &mut self,
3012 _u: &lsp_types::Uri,
3013 _p: lsp_types::Position,
3014 _s: &str,
3015 ) -> Result<Vec<lsp_types::Location>, String> {
3016 Ok(vec![])
3017 }
3018 fn definition(
3019 &mut self,
3020 _u: &lsp_types::Uri,
3021 _p: lsp_types::Position,
3022 ) -> Result<lsp_types::GotoDefinitionResponse, String> {
3023 Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
3024 }
3025 fn implementations(
3026 &mut self,
3027 _u: &lsp_types::Uri,
3028 _p: lsp_types::Position,
3029 _s: &str,
3030 ) -> Result<Vec<lsp_types::Location>, String> {
3031 Ok(vec![])
3032 }
3033 fn rename(
3034 &mut self,
3035 _u: &lsp_types::Uri,
3036 _p: lsp_types::Position,
3037 _n: &str,
3038 ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
3039 Ok(None)
3040 }
3041 fn safe_delete_preview(
3042 &mut self,
3043 _q: &crate::lsp::backend::SafeDeleteQuery,
3044 ) -> Result<crate::lsp::backend::RenamePlan, String> {
3045 Ok(self.plan.clone())
3046 }
3047 fn safe_delete_apply(
3048 &mut self,
3049 req: &crate::lsp::backend::SafeDeleteApply,
3050 ) -> Result<crate::lsp::backend::RenameResult, String> {
3051 self.applied.set(Some((req.force, req.propagate)));
3052 Ok(crate::lsp::backend::RenameResult {
3053 applied: true,
3054 changed_paths: vec!["Widget.kt".into()],
3055 })
3056 }
3057 }
3058
3059 fn safe_delete_query(abs: &str) -> crate::lsp::backend::SafeDeleteQuery {
3060 crate::lsp::backend::SafeDeleteQuery {
3061 abs_path: abs.into(),
3062 rel_path: "a.rs".into(),
3063 src_range: crate::lsp::backend::TextRange0Based {
3064 start_line: 0,
3065 start_char: 4,
3066 end_line: 0,
3067 end_char: 7,
3068 },
3069 }
3070 }
3071
3072 #[test]
3073 fn safe_delete_apply_blocks_on_remaining_refs_without_force() {
3074 let dir = tempfile::tempdir().unwrap();
3075 std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
3076 let root = dir.path().to_str().unwrap();
3077 let usage = crate::lsp::backend::UsageSite {
3078 path: "a.rs".into(),
3079 range: crate::lsp::backend::TextRange0Based {
3080 start_line: 0,
3081 start_char: 4,
3082 end_line: 0,
3083 end_char: 7,
3084 },
3085 context: None,
3086 };
3087 let plan = crate::lsp::backend::RenamePlan {
3089 usages: vec![usage.clone()],
3090 conflicts: vec![crate::lsp::backend::Conflict {
3091 path: "a.rs".into(),
3092 range: None,
3093 message: "still referenced".into(),
3094 }],
3095 };
3096 let hash = super::plan_hash(root, &plan.usages).unwrap();
3097 let q = safe_delete_query(&dir.path().join("a.rs").to_string_lossy());
3098
3099 let mut be = SafeDeleteStub {
3101 plan: plan.clone(),
3102 applied: std::cell::Cell::new(None),
3103 };
3104 let out = super::render_safe_delete_apply(&mut be, root, &q, &hash, false, false);
3105 assert!(out.contains("CONFLICT"), "got: {out}");
3106 assert_eq!(be.applied.get(), None);
3107
3108 let mut be2 = SafeDeleteStub {
3110 plan,
3111 applied: std::cell::Cell::new(None),
3112 };
3113 let out2 = super::render_safe_delete_apply(&mut be2, root, &q, &hash, true, true);
3114 assert!(
3115 out2.contains("deleted") || out2.contains("applied"),
3116 "got: {out2}"
3117 );
3118 assert_eq!(be2.applied.get(), Some((true, true)));
3119 }
3120
3121 #[test]
3122 fn safe_delete_apply_blocks_on_plan_hash_mismatch() {
3123 let dir = tempfile::tempdir().unwrap();
3124 std::fs::write(dir.path().join("a.rs"), "let foo = 1;\nfoo + foo;\n").unwrap();
3125 let root = dir.path().to_str().unwrap();
3126 let usage = crate::lsp::backend::UsageSite {
3127 path: "a.rs".into(),
3128 range: crate::lsp::backend::TextRange0Based {
3129 start_line: 0,
3130 start_char: 4,
3131 end_line: 0,
3132 end_char: 7,
3133 },
3134 context: None,
3135 };
3136 let mut be = SafeDeleteStub {
3137 plan: crate::lsp::backend::RenamePlan {
3138 usages: vec![usage],
3139 conflicts: vec![],
3140 },
3141 applied: std::cell::Cell::new(None),
3142 };
3143 let q = safe_delete_query(&dir.path().join("a.rs").to_string_lossy());
3144 let out = super::render_safe_delete_apply(&mut be, root, &q, "stalehash", false, false);
3145 assert!(out.contains("CONFLICT"), "got: {out}");
3146 assert_eq!(be.applied.get(), None);
3147 }
3148
3149 struct InlineStub {
3153 conflicts: Vec<crate::lsp::backend::Conflict>,
3154 }
3155 impl crate::lsp::backend::LspBackend for InlineStub {
3156 fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
3157 Ok(())
3158 }
3159 fn references(
3160 &mut self,
3161 _u: &lsp_types::Uri,
3162 _p: lsp_types::Position,
3163 _s: &str,
3164 ) -> Result<Vec<lsp_types::Location>, String> {
3165 Ok(vec![])
3166 }
3167 fn definition(
3168 &mut self,
3169 _u: &lsp_types::Uri,
3170 _p: lsp_types::Position,
3171 ) -> Result<lsp_types::GotoDefinitionResponse, String> {
3172 Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
3173 }
3174 fn implementations(
3175 &mut self,
3176 _u: &lsp_types::Uri,
3177 _p: lsp_types::Position,
3178 _s: &str,
3179 ) -> Result<Vec<lsp_types::Location>, String> {
3180 Ok(vec![])
3181 }
3182 fn rename(
3183 &mut self,
3184 _u: &lsp_types::Uri,
3185 _p: lsp_types::Position,
3186 _n: &str,
3187 ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
3188 Ok(None)
3189 }
3190 fn inline_preview(
3191 &mut self,
3192 _q: &crate::lsp::backend::InlineQuery,
3193 ) -> Result<crate::lsp::backend::RenamePlan, String> {
3194 Ok(crate::lsp::backend::RenamePlan {
3195 usages: vec![],
3196 conflicts: self.conflicts.clone(),
3197 })
3198 }
3199 fn inline_apply(
3200 &mut self,
3201 _r: &crate::lsp::backend::InlineApply,
3202 ) -> Result<crate::lsp::backend::RenameResult, String> {
3203 Ok(crate::lsp::backend::RenameResult {
3204 applied: true,
3205 changed_paths: vec![],
3206 })
3207 }
3208 }
3209
3210 fn inline_query(abs: &str) -> crate::lsp::backend::InlineQuery {
3211 crate::lsp::backend::InlineQuery {
3212 abs_path: abs.to_string(),
3213 rel_path: "Calc.kt".to_string(),
3214 src_range: crate::lsp::backend::TextRange0Based {
3215 start_line: 0,
3216 start_char: 0,
3217 end_line: 0,
3218 end_char: 0,
3219 },
3220 keep_definition: false,
3221 }
3222 }
3223
3224 #[test]
3225 fn handle_inline_apply_requires_plan_hash() {
3226 let args = serde_json::json!({ "action": "inline_apply", "name_path": "Calc/tmp" });
3227 let out = super::handle_inline_refactor("inline_apply", &args, "/nonexistent-root");
3228 assert!(out.contains("plan_hash"), "got: {out}");
3229 }
3230
3231 #[test]
3232 fn handle_inline_preview_without_ide_is_backend_required() {
3233 let dir = tempfile::tempdir().unwrap();
3234 std::fs::write(dir.path().join("Calc.kt"), "val tmp = 1\n").unwrap();
3235 let root = dir.path().to_str().unwrap();
3236 let args = serde_json::json!({ "action": "inline_preview", "path": "Calc.kt", "line": 1 });
3238 let out = super::handle_inline_refactor("inline_preview", &args, root);
3239 assert!(out.contains("BACKEND_REQUIRED"), "got: {out}");
3240 }
3241
3242 #[test]
3243 fn inline_apply_blocks_on_conflicts_with_no_force_path() {
3244 let mut be = InlineStub {
3246 conflicts: vec![crate::lsp::backend::Conflict {
3247 path: "Calc.kt".into(),
3248 range: None,
3249 message: "recursive".into(),
3250 }],
3251 };
3252 let dir = tempfile::tempdir().unwrap();
3253 let f = dir.path().join("Calc.kt");
3254 std::fs::write(&f, "val tmp = 1\n").unwrap();
3255 let q = inline_query(f.to_str().unwrap());
3256 let out = super::render_inline_apply(&mut be, dir.path().to_str().unwrap(), &q, "deadbeef");
3258 assert!(out.contains("CONFLICT"), "got: {out}");
3259 }
3260
3261 #[test]
3262 fn reformat_invalid_target_when_no_address() {
3263 let args = serde_json::json!({ "action": "reformat" });
3264 let out = super::handle_reformat_refactor(&args, env!("CARGO_MANIFEST_DIR"));
3265 assert!(out.contains("INVALID_TARGET"), "got: {out}");
3266 }
3267
3268 #[test]
3269 fn reformat_address_dispatch_resolves_scope() {
3270 let dir = tempfile::tempdir().unwrap();
3272 let f = dir.path().join("M.kt");
3273 std::fs::write(&f, "fun a(){}\nfun b(){}\n").unwrap();
3274 let root = dir.path().to_str().unwrap();
3275
3276 let file_args = serde_json::json!({ "action": "reformat", "path": "M.kt" });
3277 let (_abs, _rel, scope) = super::resolve_reformat_scope(&file_args, root).unwrap();
3278 assert!(matches!(scope, crate::lsp::backend::ReformatScope::File));
3279
3280 let region_args =
3281 serde_json::json!({ "action": "reformat", "path": "M.kt", "line": 1, "end_line": 2 });
3282 let (_a, _r, scope) = super::resolve_reformat_scope(®ion_args, root).unwrap();
3283 assert!(matches!(
3284 scope,
3285 crate::lsp::backend::ReformatScope::Region { .. }
3286 ));
3287 }
3288
3289 #[test]
3290 fn reformat_without_ide_is_backend_required() {
3291 let args = serde_json::json!({ "action": "reformat", "path": "M.kt" });
3292 let out = super::handle_reformat_refactor(&args, env!("CARGO_MANIFEST_DIR"));
3293 assert!(
3295 out.contains("BACKEND_REQUIRED") || out.contains("FILE_NOT_FOUND"),
3296 "got: {out}"
3297 );
3298 }
3299}