1use lsp_types::{Location, Position};
2use serde_json::Value;
3
4use crate::lsp::client::uri_to_file_path;
5
6pub fn handle(args: &Value, project_root: &str, abs_path: &str) -> String {
7 let action = args
8 .get("action")
9 .and_then(Value::as_str)
10 .unwrap_or("references");
11
12 if matches!(
13 action,
14 "replace_symbol_body" | "insert_before_symbol" | "insert_after_symbol"
15 ) {
16 return handle_symbol_edit(action, args, project_root);
17 }
18
19 if matches!(action, "rename_preview" | "rename_apply") {
20 return handle_rename_refactor(action, args, project_root);
21 }
22
23 if matches!(action, "safe_delete_preview" | "safe_delete_apply") {
24 return handle_safe_delete_refactor(action, args, project_root);
25 }
26
27 if matches!(action, "move_preview" | "move_apply") {
28 return handle_move_refactor(action, args, project_root);
29 }
30
31 if matches!(action, "inline_preview" | "inline_apply") {
32 return handle_inline_refactor(action, args, project_root);
33 }
34
35 if action == "reformat" {
36 return handle_reformat_refactor(args, project_root);
37 }
38
39 let line = args.get("line").and_then(Value::as_u64).unwrap_or(1) as u32;
40 let column = args.get("column").and_then(Value::as_u64).unwrap_or(0) as u32;
41 let scope = args
42 .get("scope")
43 .and_then(Value::as_str)
44 .unwrap_or("project");
45
46 let uri = match crate::lsp::router::open_file(abs_path, project_root) {
47 Ok(u) => u,
48 Err(e) => return format!("ERROR: {e}"),
49 };
50
51 let position = Position::new(line.saturating_sub(1), column);
52
53 if action == "rename"
56 && let Some(e) = deny_if_read_only(abs_path)
57 {
58 return e;
59 }
60
61 match action {
62 "rename" => handle_rename(args, abs_path, project_root, &uri, position),
63 "references" => handle_references(abs_path, project_root, &uri, position, scope),
64 "definition" => handle_definition(abs_path, project_root, &uri, position),
65 "implementations" => handle_implementations(abs_path, project_root, &uri, position, scope),
66 "declaration" => handle_declaration(abs_path, project_root, &uri, position),
67 "type_hierarchy" => handle_type_hierarchy(args, abs_path, project_root, &uri, position),
68 "symbols_overview" => handle_symbols_overview(abs_path, project_root, &uri),
69 "inspections" => handle_inspections(args, abs_path, project_root, &uri),
70 _ => format!(
71 "ERROR: Unknown action '{action}'. Available: rename, references, definition, \
72 implementations, declaration, type_hierarchy, symbols_overview, inspections, \
73 replace_symbol_body, insert_before_symbol, insert_after_symbol, \
74 rename_preview, rename_apply, safe_delete_preview, safe_delete_apply, \
75 move_preview, move_apply, inline_preview, inline_apply, reformat."
76 ),
77 }
78}
79
80fn deny_if_read_only(abs_path: &str) -> Option<String> {
86 crate::core::pathjail::enforce_writable(std::path::Path::new(abs_path))
87 .err()
88 .map(|e| format!("ERROR: {e}"))
89}
90
91fn handle_rename(
92 args: &Value,
93 file_path: &str,
94 project_root: &str,
95 uri: &lsp_types::Uri,
96 position: Position,
97) -> String {
98 let Some(new_name) = args.get("new_name").and_then(Value::as_str) else {
99 return "ERROR: 'new_name' parameter is required for rename.".to_string();
100 };
101
102 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
103 backend.rename(uri, position, new_name)
104 });
105
106 match result {
107 Ok(Some(edit)) => format_workspace_edit(&edit, project_root),
108 Ok(None) => "No rename edits returned by language server.".to_string(),
109 Err(e) => format!("ERROR: {e}"),
110 }
111}
112
113fn handle_references(
114 file_path: &str,
115 project_root: &str,
116 uri: &lsp_types::Uri,
117 position: Position,
118 scope: &str,
119) -> String {
120 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
121 let locs = backend.references(uri, position, scope)?;
122 Ok((locs, backend.last_truncation()))
123 });
124
125 match result {
126 Ok((locations, meta)) => {
127 let mut out = format_locations(&locations, project_root);
128 out.push_str(&truncation_note(locations.len(), meta));
129 out
130 }
131 Err(e) => format!("ERROR: {e}"),
132 }
133}
134
135fn handle_definition(
136 file_path: &str,
137 project_root: &str,
138 uri: &lsp_types::Uri,
139 position: Position,
140) -> String {
141 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
142 backend.definition(uri, position)
143 });
144
145 match result {
146 Ok(resp) => {
147 let locations = match resp {
148 lsp_types::GotoDefinitionResponse::Scalar(loc) => vec![loc],
149 lsp_types::GotoDefinitionResponse::Array(locs) => locs,
150 lsp_types::GotoDefinitionResponse::Link(links) => links
151 .into_iter()
152 .map(|l| Location {
153 uri: l.target_uri,
154 range: l.target_selection_range,
155 })
156 .collect(),
157 };
158 format_locations(&locations, project_root)
159 }
160 Err(e) => format!("ERROR: {e}"),
161 }
162}
163
164fn handle_implementations(
165 file_path: &str,
166 project_root: &str,
167 uri: &lsp_types::Uri,
168 position: Position,
169 scope: &str,
170) -> String {
171 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
172 let locs = backend.implementations(uri, position, scope)?;
173 Ok((locs, backend.last_truncation()))
174 });
175
176 match result {
177 Ok((locations, meta)) => {
178 let mut out = format_locations(&locations, project_root);
179 out.push_str(&truncation_note(locations.len(), meta));
180 out
181 }
182 Err(e) => format!("ERROR: {e}"),
183 }
184}
185
186fn handle_declaration(
187 file_path: &str,
188 project_root: &str,
189 uri: &lsp_types::Uri,
190 position: Position,
191) -> String {
192 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
193 backend.declaration(uri, position)
194 });
195
196 match result {
197 Ok(locations) => format_locations(&locations, project_root),
198 Err(e) => format!("ERROR: {e}"),
199 }
200}
201
202use crate::lsp::backend::{
203 HierarchyDirection, InspectionDiag, InspectionInfo, SymbolOverviewItem, TypeHierarchyNode,
204};
205
206#[derive(Debug)]
208pub(crate) struct Resolved {
209 pub rel_path: String,
210 pub start_line: usize,
211 pub end_line: usize,
212}
213
214pub(crate) fn apply_symbol_edit(
218 action: &str,
219 project_root: &str,
220 edit: &crate::lsp::backend::RangeEdit,
221) -> Result<crate::lsp::backend::EditResult, String> {
222 use crate::lsp::backend::LspBackend;
223 use crate::lsp::port_discovery;
224
225 let mut backend: Box<dyn LspBackend> =
226 if let Some(pf) = port_discovery::read_port_file(project_root) {
227 if port_discovery::pid_alive(pf.pid) && port_discovery::health_ok(&pf) {
228 Box::new(crate::lsp::jetbrains_backend::JetBrainsHttpBackend::new(
229 pf.port,
230 pf.token,
231 project_root.to_string(),
232 pf.pid,
233 ))
234 } else {
235 Box::new(crate::lsp::edit_apply::HeadlessBackend)
236 }
237 } else {
238 Box::new(crate::lsp::edit_apply::HeadlessBackend)
239 };
240
241 match action {
242 "replace_symbol_body" => backend.replace_symbol_body(edit),
243 "insert_before_symbol" => backend.insert_before_symbol(edit),
244 "insert_after_symbol" => backend.insert_after_symbol(edit),
245 other => Err(format!("INTERNAL: not an edit action: {other}")),
246 }
247}
248
249pub(crate) fn anchor_indent(content: &str, line: usize) -> String {
251 content
252 .lines()
253 .nth(line.saturating_sub(1))
254 .map(|l| l.chars().take_while(|c| *c == ' ' || *c == '\t').collect())
255 .unwrap_or_default()
256}
257
258pub(crate) fn reindent_first_line(text: &str, indent: &str) -> String {
262 if text.starts_with(' ') || text.starts_with('\t') || indent.is_empty() {
263 return text.to_string();
264 }
265 format!("{indent}{text}")
266}
267
268fn container_matches_ancestor(name: &str, ancestor: &str) -> bool {
275 if name == ancestor {
276 return true;
277 }
278 match name.rsplit_once(" for ") {
279 Some((_, target)) => target.split('<').next().unwrap_or(target).trim() == ancestor,
280 None => false,
281 }
282}
283
284pub(crate) fn resolve_name_path(name_path: &str, project_root: &str) -> Result<Resolved, String> {
290 resolve_name_path_scoped(name_path, project_root, None)
291}
292
293pub(crate) fn resolve_name_path_scoped(
294 name_path: &str,
295 project_root: &str,
296 file_scope: Option<&str>,
297) -> Result<Resolved, String> {
298 use crate::core::graph_provider;
299 let open = graph_provider::open_or_build(project_root)
300 .ok_or_else(|| "NO_SYMBOL: no symbol index available".to_string())?;
301 let gp = &open.provider;
302
303 let segments: Vec<&str> = name_path.split('/').filter(|s| !s.is_empty()).collect();
304 let leaf = *segments
305 .last()
306 .ok_or_else(|| "NO_SYMBOL: empty name_path".to_string())?;
307
308 let mut leaves: Vec<_> = gp
310 .find_symbols(leaf, None, None)
311 .into_iter()
312 .filter(|s| s.name == leaf)
313 .collect();
314
315 if let Some(scope) = file_scope {
317 leaves.retain(|s| s.file.ends_with(scope) || s.file == scope);
318 }
319
320 if segments.len() >= 2 {
321 let ancestor = segments[segments.len() - 2];
322 let parents: Vec<_> = gp
323 .find_symbols(ancestor, None, None)
324 .into_iter()
325 .filter(|s| container_matches_ancestor(&s.name, ancestor))
326 .collect();
327 leaves.retain(|leaf_sym| {
328 parents.iter().any(|p| {
329 p.file == leaf_sym.file
330 && p.start_line <= leaf_sym.start_line
331 && leaf_sym.end_line <= p.end_line
332 })
333 });
334 }
335
336 match leaves.len() {
337 0 => Err(format!(
338 "NO_SYMBOL: '{name_path}' did not resolve to any indexed symbol"
339 )),
340 1 => Ok(Resolved {
341 rel_path: leaves[0].file.clone(),
342 start_line: leaves[0].start_line,
343 end_line: leaves[0].end_line,
344 }),
345 _ => {
346 let mut msg = format!(
347 "AMBIGUOUS_SYMBOL: '{name_path}' matches {} symbols; qualify it:\n",
348 leaves.len()
349 );
350 for s in leaves.iter().take(10) {
351 msg.push_str(&format!(
352 " {}:{} (L{}-{})\n",
353 s.file, s.name, s.start_line, s.end_line
354 ));
355 }
356 Err(msg)
357 }
358 }
359}
360
361pub(crate) fn usage_range_text(
365 project_root: &str,
366 u: &crate::lsp::backend::UsageSite,
367) -> Result<String, String> {
368 let abs = crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &u.path)
369 .map_err(|e| format!("CONFLICT: usage path blocked by jail: {e}"))?;
370 let content =
371 std::fs::read_to_string(&abs).map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
372 let s = crate::lsp::edit_apply::offset_of(&content, u.range.start_line, u.range.start_char)?;
373 let e = crate::lsp::edit_apply::offset_of(&content, u.range.end_line, u.range.end_char)?;
374 if e < s {
375 return Err("POSITION_OUT_OF_RANGE: end before start".to_string());
376 }
377 Ok(content[s..e].to_string())
378}
379
380pub(crate) fn plan_hash(
385 project_root: &str,
386 usages: &[crate::lsp::backend::UsageSite],
387) -> Result<String, String> {
388 use crate::lsp::backend::TextRange0Based;
389 let mut rows: Vec<(String, TextRange0Based, String)> = Vec::with_capacity(usages.len());
390 for u in usages {
391 let text = usage_range_text(project_root, u)?;
392 rows.push((u.path.clone(), u.range, text));
393 }
394 rows.sort_by(|a, b| {
395 a.0.cmp(&b.0)
396 .then(a.1.start_line.cmp(&b.1.start_line))
397 .then(a.1.start_char.cmp(&b.1.start_char))
398 .then(a.1.end_line.cmp(&b.1.end_line))
399 .then(a.1.end_char.cmp(&b.1.end_char))
400 });
401 let mut canon = String::new();
402 for (path, r, text) in &rows {
403 canon.push_str(&format!(
404 "{path}|{}:{}-{}:{}|{text}\n",
405 r.start_line, r.start_char, r.end_line, r.end_char
406 ));
407 }
408 Ok(crate::core::hasher::hash_hex(canon.as_bytes()))
409}
410
411mod ops;
412#[allow(clippy::wildcard_imports)]
413use ops::*;
414
415fn parse_direction(args: &Value) -> HierarchyDirection {
416 match args.get("direction").and_then(Value::as_str) {
417 Some("subtypes") => HierarchyDirection::Subtypes,
418 _ => HierarchyDirection::Supertypes,
419 }
420}
421
422fn handle_type_hierarchy(
423 args: &Value,
424 file_path: &str,
425 project_root: &str,
426 uri: &lsp_types::Uri,
427 position: Position,
428) -> String {
429 let direction = parse_direction(args);
430 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
431 let tree = backend.type_hierarchy(uri, position, direction)?;
432 Ok((tree, backend.last_truncation()))
433 });
434 match result {
435 Ok((tree, meta)) => {
436 let mut out = format_type_hierarchy(&tree);
437 if matches!(meta, Some(m) if m.truncated) {
438 out.push_str("\n(truncated — depth/node cap reached)\n");
439 }
440 out
441 }
442 Err(e) => format!("ERROR: {e}"),
443 }
444}
445
446fn handle_symbols_overview(file_path: &str, project_root: &str, uri: &lsp_types::Uri) -> String {
447 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
448 let items = backend.symbols_overview(uri)?;
449 Ok((items, backend.last_truncation()))
450 });
451 match result {
452 Ok((items, meta)) => {
453 let mut out = format_symbols_overview(&items);
454 out.push_str(&truncation_note(items.len(), meta));
455 out
456 }
457 Err(e) => format!("ERROR: {e}"),
458 }
459}
460
461fn handle_symbol_edit(action: &str, args: &Value, project_root: &str) -> String {
462 let explicit_path = args.get("path").and_then(Value::as_str);
463
464 let (rel_path, start_line, end_line) = if let Some(np) =
465 args.get("name_path").and_then(Value::as_str)
466 {
467 match resolve_name_path(np, project_root) {
468 Ok(r) => {
469 if let Some(caller_path) = explicit_path {
475 let resolved_abs = match crate::core::path_resolve::resolve_tool_path(
476 Some(project_root),
477 None,
478 &r.rel_path,
479 ) {
480 Ok(p) => p,
481 Err(e) => return format!("ERROR: path blocked by jail: {e}"),
482 };
483 let caller_abs = match crate::core::path_resolve::resolve_tool_path(
484 Some(project_root),
485 None,
486 caller_path,
487 ) {
488 Ok(p) => p,
489 Err(e) => return format!("ERROR: path blocked by jail: {e}"),
490 };
491 let resolved_real = std::fs::canonicalize(&resolved_abs)
492 .unwrap_or_else(|_| std::path::PathBuf::from(&resolved_abs));
493 let caller_real = std::fs::canonicalize(&caller_abs)
494 .unwrap_or_else(|_| std::path::PathBuf::from(&caller_abs));
495 if resolved_real != caller_real {
496 return format!(
497 "ERROR: WORKTREE_MISMATCH: symbol '{np}' resolved to '{resolved_abs}' \
498 but caller specified '{caller_abs}'. The symbol index points to a \
499 different checkout (likely a git worktree mismatch). \
500 Use op=replace_lines with explicit line range instead, \
501 or re-index from the correct root.",
502 );
503 }
504 (caller_path.to_string(), r.start_line, r.end_line)
505 } else {
506 (r.rel_path, r.start_line, r.end_line)
507 }
508 }
509 Err(e) => return format!("ERROR: {e}"),
510 }
511 } else {
512 let Some(path) = explicit_path else {
513 return "ERROR: provide 'name_path' or 'path'+'line' for symbol edits.".to_string();
514 };
515 let line = args.get("line").and_then(Value::as_u64).unwrap_or(0) as usize;
516 let end = args
517 .get("end_line")
518 .and_then(Value::as_u64)
519 .unwrap_or(line as u64) as usize;
520 if line == 0 {
521 return "ERROR: 'line' is required (1-based) when using the path fallback.".to_string();
522 }
523 (path.to_string(), line, end)
524 };
525
526 let abs_path =
528 match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
529 Ok(p) => p,
530 Err(e) => return format!("ERROR: path blocked by jail: {e}"),
531 };
532 if let Some(e) = deny_if_read_only(&abs_path) {
534 return e;
535 }
536
537 let content = match std::fs::read_to_string(&abs_path) {
538 Ok(c) => c,
539 Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
540 };
541
542 let expected_hash = args
544 .get("expected_hash")
545 .and_then(Value::as_str)
546 .map(String::from);
547 let (range, text) = match action {
548 "replace_symbol_body" => {
549 let Some(new_body) = args.get("new_body").and_then(Value::as_str) else {
550 return "ERROR: 'new_body' is required for replace_symbol_body.".to_string();
551 };
552 let end_col = content
553 .lines()
554 .nth(end_line.saturating_sub(1))
555 .map_or(0, str::len) as u32;
556 (
557 crate::lsp::backend::TextRange0Based {
558 start_line: (start_line - 1) as u32,
559 start_char: 0,
560 end_line: (end_line - 1) as u32,
561 end_char: end_col,
562 },
563 new_body.to_string(),
564 )
565 }
566 "insert_before_symbol" | "insert_after_symbol" => {
567 let Some(t) = args.get("text").and_then(Value::as_str) else {
568 return format!("ERROR: 'text' is required for {action}.");
569 };
570 let indent = anchor_indent(&content, start_line);
571 let final_text = format!("{}\n", reindent_first_line(t, &indent));
572 let insert_line = if action == "insert_before_symbol" {
573 (start_line - 1) as u32
574 } else {
575 end_line as u32
576 };
577 (
578 crate::lsp::backend::TextRange0Based {
579 start_line: insert_line,
580 start_char: 0,
581 end_line: insert_line,
582 end_char: 0,
583 },
584 final_text,
585 )
586 }
587 other => return format!("ERROR: INTERNAL: not an edit action: {other}"),
588 };
589
590 if let Some(exp) = &expected_hash {
595 let s =
596 match crate::lsp::edit_apply::offset_of(&content, range.start_line, range.start_char) {
597 Ok(o) => o,
598 Err(e) => return format!("ERROR: {e}"),
599 };
600 let e = match crate::lsp::edit_apply::offset_of(&content, range.end_line, range.end_char) {
601 Ok(o) => o,
602 Err(e) => return format!("ERROR: {e}"),
603 };
604 if e < s {
605 return "ERROR: POSITION_OUT_OF_RANGE: end before start".to_string();
606 }
607 let actual = crate::core::hasher::hash_hex(&content.as_bytes()[s..e]);
608 if *exp != actual {
609 return format!(
610 "ERROR: CONFLICT: range hash mismatch (expected={exp}, actual={actual})"
611 );
612 }
613 }
614
615 let edit = crate::lsp::backend::RangeEdit {
616 abs_path,
617 rel_path,
618 range,
619 text,
620 expected_hash,
621 };
622
623 let ext = std::path::Path::new(&edit.abs_path)
626 .extension()
627 .and_then(std::ffi::OsStr::to_str)
628 .unwrap_or("");
629 {
630 let start_off =
631 crate::lsp::edit_apply::offset_of(&content, range.start_line, range.start_char)
632 .unwrap_or(0);
633 let end_off = crate::lsp::edit_apply::offset_of(&content, range.end_line, range.end_char)
634 .unwrap_or(content.len());
635 let mut hypothetical =
636 String::with_capacity(content.len() - (end_off - start_off) + edit.text.len());
637 hypothetical.push_str(&content[..start_off]);
638 hypothetical.push_str(&edit.text);
639 hypothetical.push_str(&content[end_off..]);
640 if let Some(reason) = crate::core::syntax_validate::gate_edit(ext, &content, &hypothetical)
641 {
642 return reason;
643 }
644 }
645
646 match apply_symbol_edit(action, project_root, &edit) {
648 Ok(res) => format_edit_result(action, &edit.abs_path, &res),
649 Err(e) => format!("ERROR: {e}"),
650 }
651}
652
653fn format_edit_result(
654 action: &str,
655 abs_path: &str,
656 res: &crate::lsp::backend::EditResult,
657) -> String {
658 if !res.applied {
659 return format!("{action}: not applied.");
660 }
661 let r = res.new_range;
662 let body = if res.diff.is_empty() {
663 res.edited_text.clone()
664 } else {
665 res.diff.clone()
666 };
667 format!(
669 "{action} applied {abs_path} (L{}:{}-L{}:{}):\n{}",
670 r.start_line + 1,
671 r.start_char,
672 r.end_line + 1,
673 r.end_char,
674 body
675 )
676}
677
678fn handle_inspections(
679 args: &Value,
680 file_path: &str,
681 project_root: &str,
682 uri: &lsp_types::Uri,
683) -> String {
684 let mode = args.get("mode").and_then(Value::as_str).unwrap_or("run");
685 match mode {
686 "run" => {
687 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
688 let diags = backend.inspections(uri)?;
689 Ok((diags, backend.last_truncation()))
690 });
691 match result {
692 Ok((diags, meta)) => {
693 let mut out = format_inspections(&diags);
694 out.push_str(&truncation_note(diags.len(), meta));
695 out
696 }
697 Err(e) => format!("ERROR: {e}"),
698 }
699 }
700 "list" => {
701 let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
702 let items = backend.list_inspections()?;
703 Ok((items, backend.last_truncation()))
704 });
705 match result {
706 Ok((items, meta)) => {
707 let mut out = format_inspection_list(&items);
708 out.push_str(&truncation_note(items.len(), meta));
709 out
710 }
711 Err(e) => format!("ERROR: {e}"),
712 }
713 }
714 other => format!("ERROR: Unknown mode '{other}' for inspections. Available: run, list."),
715 }
716}
717
718fn format_inspections(diags: &[InspectionDiag]) -> String {
719 if diags.is_empty() {
720 return "No inspection findings.".to_string();
721 }
722 let mut out = format!("{} finding(s):\n", diags.len());
723 for d in diags {
724 out.push_str(&format!(
725 " {}:{} {} {}\n",
726 d.path, d.line, d.severity, d.message
727 ));
728 }
729 out
730}
731
732fn format_inspection_list(items: &[InspectionInfo]) -> String {
733 if items.is_empty() {
734 return "No inspections enabled.".to_string();
735 }
736 let mut out = format!("{} inspection(s):\n", items.len());
737 for i in items {
738 out.push_str(&format!(" {} {} {}\n", i.id, i.name, i.severity));
739 }
740 out
741}
742
743fn truncation_note(shown: usize, meta: Option<crate::lsp::backend::Truncation>) -> String {
744 match meta {
745 Some(m) if m.truncated => {
746 format!("\n(truncated — showing {shown} of {})\n", m.total)
747 }
748 _ => String::new(),
749 }
750}
751
752fn format_type_hierarchy(root: &TypeHierarchyNode) -> String {
753 fn walk(node: &TypeHierarchyNode, depth: usize, out: &mut String) {
754 let indent = " ".repeat(depth);
755 out.push_str(&format!(
756 "{indent}{} ({}:{})\n",
757 node.name, node.path, node.line
758 ));
759 for child in &node.children {
760 walk(child, depth + 1, out);
761 }
762 }
763 let mut out = String::new();
764 walk(root, 0, &mut out);
765 out
766}
767
768fn format_symbols_overview(items: &[SymbolOverviewItem]) -> String {
769 if items.is_empty() {
770 return "No symbols found.".to_string();
771 }
772 let mut out = format!("{} symbol(s):\n", items.len());
773 for item in items {
774 out.push_str(&format!(
775 " {} {} (line {})\n",
776 item.kind, item.name, item.line
777 ));
778 }
779 out
780}
781
782fn format_locations(locations: &[Location], project_root: &str) -> String {
783 if locations.is_empty() {
784 return "No results found.".to_string();
785 }
786
787 let mut out = format!("{} location(s):\n", locations.len());
788 for loc in locations {
789 let path = uri_to_file_path(&loc.uri).map_or_else(
790 || loc.uri.as_str().to_string(),
791 |p| {
792 p.strip_prefix(project_root)
793 .map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
794 .unwrap_or(p)
795 },
796 );
797
798 let line = loc.range.start.line + 1;
799 let col = loc.range.start.character;
800 out.push_str(&format!(" {path}:{line}:{col}\n"));
801 }
802 out
803}
804
805fn format_workspace_edit(edit: &lsp_types::WorkspaceEdit, project_root: &str) -> String {
806 let mut out = String::from("Rename edits:\n");
807 let mut file_count = 0;
808 let mut edit_count = 0;
809
810 if let Some(ref changes) = edit.changes {
811 for (uri, edits) in changes {
812 let path = uri_to_file_path(uri).map_or_else(
813 || uri.as_str().to_string(),
814 |p| {
815 p.strip_prefix(project_root)
816 .map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
817 .unwrap_or(p)
818 },
819 );
820
821 file_count += 1;
822 out.push_str(&format!(" {path}: {} edit(s)\n", edits.len()));
823 for e in edits {
824 edit_count += 1;
825 let line = e.range.start.line + 1;
826 out.push_str(&format!(" L{line}: -> \"{}\"\n", e.new_text));
827 }
828 }
829 }
830
831 if let Some(ref doc_changes) = edit.document_changes {
832 match doc_changes {
833 lsp_types::DocumentChanges::Edits(edits) => {
834 for text_edit in edits {
835 let path = uri_to_file_path(&text_edit.text_document.uri)
836 .unwrap_or_else(|| text_edit.text_document.uri.as_str().to_string());
837 file_count += 1;
838 let edits_len = text_edit.edits.len();
839 edit_count += edits_len;
840 out.push_str(&format!(" {path}: {edits_len} edit(s)\n"));
841 }
842 }
843 lsp_types::DocumentChanges::Operations(ops) => {
844 for op in ops {
845 if let lsp_types::DocumentChangeOperation::Edit(text_edit) = op {
846 let path = uri_to_file_path(&text_edit.text_document.uri)
847 .unwrap_or_else(|| text_edit.text_document.uri.as_str().to_string());
848 file_count += 1;
849 let edits_len = text_edit.edits.len();
850 edit_count += edits_len;
851 out.push_str(&format!(" {path}: {edits_len} edit(s)\n"));
852 }
853 }
854 }
855 }
856 }
857
858 out.push_str(&format!(
859 "\nTotal: {edit_count} edit(s) across {file_count} file(s)."
860 ));
861 out
862}
863
864#[cfg(test)]
865mod tests;
866#[cfg(test)]
867mod tests_ops;