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