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