Skip to main content

lean_ctx/tools/ctx_refactor/
mod.rs

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    // #475: the IDE symbol `rename` rewrites the target file in place; deny when
54    // it sits inside a read-only root (the other read actions below never write).
55    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
80/// #475 read-only-roots default-deny for refactor writes. Returns an early
81/// `ERROR: …` string when `abs_path` resolves inside a configured read-only
82/// root, `None` otherwise. Two-phase `*_preview` actions only read and are
83/// never gated; every apply / symbol-edit / reformat / rename path routes its
84/// resolved target(s) through this before any IDE or headless write.
85fn 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/// A resolved symbol location (project-relative path + 1-based inclusive line span).
207#[derive(Debug)]
208pub(crate) struct Resolved {
209    pub rel_path: String,
210    pub start_line: usize,
211    pub end_line: usize,
212}
213
214/// Apply a resolved edit. IDE-first: a live JetBrains backend (port file +
215/// liveness, mirroring router::select_backend) handles it via WriteCommandAction;
216/// otherwise the headless local_range_write applies the identical bytes.
217pub(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
249/// Leading whitespace of the 1-based `line` in `content` (anchor indentation).
250pub(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
258/// Prefix `indent` to the first line of `text` iff that line has no leading
259/// whitespace of its own (deterministic; the same Rust computes it for both
260/// apply paths, so the wire text is byte-identical).
261pub(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
268/// True if symbol `name` denotes a container for type `ancestor`: the bare type
269/// itself (struct/enum/inherent `impl Type`) — exact match — or a trait impl,
270/// whose indexed name is `<Trait> for <Type>` (see the round-trip note in
271/// graph_provider.rs). Generic args on the impl target (`… for Type<T>`) are
272/// stripped so `Type/method` still resolves. Language-agnostic: non-Rust
273/// container names never contain `" for "`, so only the exact branch applies.
274fn 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
284/// Resolve a `name_path` (`Class/method` or bare `name`) to a single symbol via
285/// the tree-sitter index (spec v2a §3/§5.3). Disambiguates a qualified path by
286/// enclosing-range containment (ancestor symbol's line span contains the leaf's).
287/// #845: optional `file_scope` narrows candidates to a single file, preventing
288/// AMBIGUOUS_SYMBOL for common names like `Dispose` that appear repo-wide.
289pub(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    // Exact-name leaf candidates (case-sensitive — the index may substring-match).
309    let mut leaves: Vec<_> = gp
310        .find_symbols(leaf, None, None)
311        .into_iter()
312        .filter(|s| s.name == leaf)
313        .collect();
314
315    // #845: narrow by file when the caller provides a scope.
316    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
361/// Read the current on-disk text covered by a usage's range, jail-checking its
362/// path first. Out-of-jail / unreadable / bad range → `Err` (spec §5.4 Multi-File
363/// jail: every plugin-reported path is re-checked against `project_root`).
364pub(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
380/// Stateless Multi-File integrity guard (spec §5.2). BLAKE3 over the usages
381/// canonicalized by sorted `(path, range)` plus each usage's *current* on-disk
382/// text. `context` is display-only and intentionally excluded. Re-built in
383/// `rename_apply` and compared → mismatch = `CONFLICT` (TOCTOU).
384pub(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                // #803 WORKTREE SAFETY: when the caller also provided `path`,
470                // verify the index-resolved file matches the caller's target.
471                // In git worktrees the index may point to the main checkout
472                // while the caller works in a linked worktree — writing to the
473                // wrong root causes silent data loss.
474                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    // 2) PathJail on the resolved path (v1 §4.5 seam — critical before writes).
527    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    // #475: replace/insert symbol edits always write; deny inside a read-only root.
533    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    // 3) Build the canonical range + final wire text per action.
543    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    // CONFLICT guard (BLAKE3, same source as headless local_range_write): verify
591    // expected_hash against the current on-disk range BEFORE dispatch. This makes
592    // the IDE path enforce CONFLICT identically to the headless path (which also
593    // re-checks atomically). hash_hex == blake3::hash(...).to_hex().
594    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    // 4) Pre-write syntax gate (#836): reject edits that would break a cleanly
624    //    parsing file. Build the hypothetical new content and check it.
625    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    // 5) Dispatch (IDE-first, headless fallback) + format.
647    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    // #803: include absolute path so callers can verify the write target
668    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;