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