Skip to main content

lean_ctx/tools/
ctx_refactor.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).
287pub(crate) fn resolve_name_path(name_path: &str, project_root: &str) -> Result<Resolved, String> {
288    use crate::core::graph_provider;
289    let open = graph_provider::open_or_build(project_root)
290        .ok_or_else(|| "NO_SYMBOL: no symbol index available".to_string())?;
291    let gp = &open.provider;
292
293    let segments: Vec<&str> = name_path.split('/').filter(|s| !s.is_empty()).collect();
294    let leaf = *segments
295        .last()
296        .ok_or_else(|| "NO_SYMBOL: empty name_path".to_string())?;
297
298    // Exact-name leaf candidates (case-sensitive — the index may substring-match).
299    let mut leaves: Vec<_> = gp
300        .find_symbols(leaf, None, None)
301        .into_iter()
302        .filter(|s| s.name == leaf)
303        .collect();
304
305    if segments.len() >= 2 {
306        let ancestor = segments[segments.len() - 2];
307        let parents: Vec<_> = gp
308            .find_symbols(ancestor, None, None)
309            .into_iter()
310            .filter(|s| container_matches_ancestor(&s.name, ancestor))
311            .collect();
312        leaves.retain(|leaf_sym| {
313            parents.iter().any(|p| {
314                p.file == leaf_sym.file
315                    && p.start_line <= leaf_sym.start_line
316                    && leaf_sym.end_line <= p.end_line
317            })
318        });
319    }
320
321    match leaves.len() {
322        0 => Err(format!(
323            "NO_SYMBOL: '{name_path}' did not resolve to any indexed symbol"
324        )),
325        1 => Ok(Resolved {
326            rel_path: leaves[0].file.clone(),
327            start_line: leaves[0].start_line,
328            end_line: leaves[0].end_line,
329        }),
330        _ => {
331            let mut msg = format!(
332                "AMBIGUOUS_SYMBOL: '{name_path}' matches {} symbols; qualify it:\n",
333                leaves.len()
334            );
335            for s in leaves.iter().take(10) {
336                msg.push_str(&format!(
337                    "  {}:{} (L{}-{})\n",
338                    s.file, s.name, s.start_line, s.end_line
339                ));
340            }
341            Err(msg)
342        }
343    }
344}
345
346/// Read the current on-disk text covered by a usage's range, jail-checking its
347/// path first. Out-of-jail / unreadable / bad range → `Err` (spec §5.4 Multi-File
348/// jail: every plugin-reported path is re-checked against `project_root`).
349pub(crate) fn usage_range_text(
350    project_root: &str,
351    u: &crate::lsp::backend::UsageSite,
352) -> Result<String, String> {
353    let abs = crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &u.path)
354        .map_err(|e| format!("CONFLICT: usage path blocked by jail: {e}"))?;
355    let content =
356        std::fs::read_to_string(&abs).map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
357    let s = crate::lsp::edit_apply::offset_of(&content, u.range.start_line, u.range.start_char)?;
358    let e = crate::lsp::edit_apply::offset_of(&content, u.range.end_line, u.range.end_char)?;
359    if e < s {
360        return Err("POSITION_OUT_OF_RANGE: end before start".to_string());
361    }
362    Ok(content[s..e].to_string())
363}
364
365/// Stateless Multi-File integrity guard (spec §5.2). BLAKE3 over the usages
366/// canonicalized by sorted `(path, range)` plus each usage's *current* on-disk
367/// text. `context` is display-only and intentionally excluded. Re-built in
368/// `rename_apply` and compared → mismatch = `CONFLICT` (TOCTOU).
369pub(crate) fn plan_hash(
370    project_root: &str,
371    usages: &[crate::lsp::backend::UsageSite],
372) -> Result<String, String> {
373    use crate::lsp::backend::TextRange0Based;
374    let mut rows: Vec<(String, TextRange0Based, String)> = Vec::with_capacity(usages.len());
375    for u in usages {
376        let text = usage_range_text(project_root, u)?;
377        rows.push((u.path.clone(), u.range, text));
378    }
379    rows.sort_by(|a, b| {
380        a.0.cmp(&b.0)
381            .then(a.1.start_line.cmp(&b.1.start_line))
382            .then(a.1.start_char.cmp(&b.1.start_char))
383            .then(a.1.end_line.cmp(&b.1.end_line))
384            .then(a.1.end_char.cmp(&b.1.end_char))
385    });
386    let mut canon = String::new();
387    for (path, r, text) in &rows {
388        canon.push_str(&format!(
389            "{path}|{}:{}-{}:{}|{text}\n",
390            r.start_line, r.start_char, r.end_line, r.end_char
391        ));
392    }
393    Ok(crate::core::hasher::hash_hex(canon.as_bytes()))
394}
395
396/// Resolve the rename target: `name_path` (primary, reuse v2a) or `path`+`line`
397/// (+`end_line`) fallback. Returns `(rel_path, start_line, end_line)` 1-based incl.
398fn resolve_rename_target(
399    args: &Value,
400    project_root: &str,
401) -> Result<(String, usize, usize), String> {
402    if let Some(np) = args.get("name_path").and_then(Value::as_str) {
403        let r = resolve_name_path(np, project_root)?;
404        Ok((r.rel_path, r.start_line, r.end_line))
405    } else {
406        let path = args
407            .get("path")
408            .and_then(Value::as_str)
409            .ok_or_else(|| "provide 'name_path' or 'path'+'line' for rename.".to_string())?;
410        let line = args.get("line").and_then(Value::as_u64).unwrap_or(0) as usize;
411        let end = args
412            .get("end_line")
413            .and_then(Value::as_u64)
414            .unwrap_or(line as u64) as usize;
415        if line == 0 {
416            return Err("'line' is required (1-based) when using the path fallback.".to_string());
417        }
418        Ok((path.to_string(), line, end))
419    }
420}
421
422/// Deterministic 3-stage Backing-B reachability gate (spec §3.1, v1-§8): live
423/// port file + pid alive + `/health` ping. Any miss → `BACKEND_REQUIRED` BEFORE
424/// any rename HTTP call. NO fallback to Backing A (no IDE-grade rename there).
425fn live_jetbrains_backend(
426    project_root: &str,
427) -> Result<Box<dyn crate::lsp::backend::LspBackend>, String> {
428    use crate::lsp::port_discovery;
429    if let Some(pf) = port_discovery::read_port_file(project_root)
430        && port_discovery::pid_alive(pf.pid)
431        && port_discovery::health_ok(&pf)
432    {
433        return Ok(Box::new(
434            crate::lsp::jetbrains_backend::JetBrainsHttpBackend::new(
435                pf.port,
436                pf.token,
437                project_root.to_string(),
438                pf.pid,
439            ),
440        ));
441    }
442    Err("BACKEND_REQUIRED: rename requires a running JetBrains IDE \
443         (no live port file / health check failed)"
444        .to_string())
445}
446
447/// Phase 1 renderer: ask Backing B for usages+conflicts, build the stateless
448/// plan_hash, and present the blast radius (files, usage count, conflicts).
449fn render_rename_preview(
450    backend: &mut dyn crate::lsp::backend::LspBackend,
451    project_root: &str,
452    query: &crate::lsp::backend::RenameQuery,
453    new_name: &str,
454) -> String {
455    let plan = match backend.rename_preview(query) {
456        Ok(p) => p,
457        Err(e) => return format!("ERROR: {e}"),
458    };
459    let hash = match plan_hash(project_root, &plan.usages) {
460        Ok(h) => h,
461        Err(e) => return format!("ERROR: {e}"),
462    };
463    let mut usage_files: Vec<&str> = plan.usages.iter().map(|u| u.path.as_str()).collect();
464    usage_files.sort_unstable();
465    usage_files.dedup();
466    let mut all_files: Vec<&str> = usage_files.clone();
467    all_files.push(query.rel_path.as_str());
468    all_files.sort_unstable();
469    all_files.dedup();
470    let mut out = format!(
471        "rename_preview: '{}' → '{new_name}'\n  usages: {}\n  files: {}\n  plan_hash: {hash}\n",
472        query.rel_path,
473        plan.usages.len(),
474        all_files.len(),
475    );
476    if !plan.conflicts.is_empty() {
477        out.push_str(&format!(
478            "  conflicts: {} (rename_apply blocks unless force=true)\n",
479            plan.conflicts.len()
480        ));
481        for c in &plan.conflicts {
482            out.push_str(&format!("    {}: {}\n", c.path, c.message));
483        }
484    }
485    for f in &usage_files {
486        let n = plan.usages.iter().filter(|u| u.path == **f).count();
487        out.push_str(&format!("  {f}: {n} usage(s)\n"));
488    }
489    out
490}
491
492/// Phase 2 renderer: re-fetch usages, enforce the plan_hash (TOCTOU) + conflict
493/// gates in Rust, then run the IDE Multi-File transaction and evict changed files.
494fn render_rename_apply(
495    backend: &mut dyn crate::lsp::backend::LspBackend,
496    project_root: &str,
497    query: &crate::lsp::backend::RenameQuery,
498    new_name: &str,
499    expected_hash: &str,
500    force: bool,
501) -> String {
502    let plan = match backend.rename_preview(query) {
503        Ok(p) => p,
504        Err(e) => return format!("ERROR: {e}"),
505    };
506    let mut pre: Vec<(String, u32, String)> = Vec::with_capacity(plan.usages.len());
507    for u in &plan.usages {
508        match usage_range_text(project_root, u) {
509            Ok(t) => pre.push((u.path.clone(), u.range.start_line + 1, t)),
510            Err(e) => return format!("ERROR: {e}"),
511        }
512    }
513    let actual = match plan_hash(project_root, &plan.usages) {
514        Ok(h) => h,
515        Err(e) => return format!("ERROR: {e}"),
516    };
517    if actual != expected_hash {
518        return format!(
519            "ERROR: CONFLICT: plan_hash mismatch (source changed since preview; \
520             expected={expected_hash}, actual={actual})"
521        );
522    }
523    if !plan.conflicts.is_empty() && !force {
524        return format!(
525            "ERROR: CONFLICT: {} refactoring conflict(s); pass force=true to override",
526            plan.conflicts.len()
527        );
528    }
529
530    let apply = crate::lsp::backend::RenameApply {
531        abs_path: query.abs_path.clone(),
532        rel_path: query.rel_path.clone(),
533        target_range: query.target_range,
534        new_name: new_name.to_string(),
535        force,
536    };
537    let res = match backend.rename_apply(&apply) {
538        Ok(r) => r,
539        Err(e) => return format!("ERROR: {e}"),
540    };
541
542    // Jail-check + cache-evict each changed file (Multi-File coherence, spec §9).
543    for cp in &res.changed_paths {
544        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
545            Ok(abs) => crate::core::cli_cache::invalidate(&abs),
546            Err(e) => return format!("ERROR: CONFLICT: changed path blocked by jail: {e}"),
547        }
548    }
549
550    let mut out = format!(
551        "rename_apply: '{}' → '{new_name}' applied\n  changed files: {}\n  usages: {}\n",
552        query.rel_path,
553        res.changed_paths.len(),
554        pre.len(),
555    );
556    for (path, line, old) in &pre {
557        out.push_str(&format!("  {path}:{line}  \"{old}\" → \"{new_name}\"\n"));
558    }
559    out
560}
561
562/// Entry for the Two-Phase rename actions. Resolves the target (name_path / pos),
563/// double-jails, requires a live IDE, then dispatches to the preview/apply renderer.
564fn handle_rename_refactor(action: &str, args: &Value, project_root: &str) -> String {
565    let Some(new_name) = args.get("new_name").and_then(Value::as_str) else {
566        return "ERROR: 'new_name' is required for rename.".to_string();
567    };
568    if action == "rename_apply" && args.get("plan_hash").and_then(Value::as_str).is_none() {
569        return "ERROR: 'plan_hash' is required for rename_apply (run rename_preview first)."
570            .to_string();
571    }
572
573    let (rel_path, start_line, end_line) = match resolve_rename_target(args, project_root) {
574        Ok(t) => t,
575        Err(e) => return format!("ERROR: {e}"),
576    };
577    let abs_path =
578        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
579            Ok(p) => p,
580            Err(e) => return format!("ERROR: path blocked by jail: {e}"),
581        };
582    // #475: rename_apply rewrites the file in place; rename_preview only reads.
583    if action == "rename_apply"
584        && let Some(e) = deny_if_read_only(&abs_path)
585    {
586        return e;
587    }
588    let content = match std::fs::read_to_string(&abs_path) {
589        Ok(c) => c,
590        Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
591    };
592    let end_col = content
593        .lines()
594        .nth(end_line.saturating_sub(1))
595        .map_or(0, str::len) as u32;
596    let target_range = crate::lsp::backend::TextRange0Based {
597        start_line: (start_line - 1) as u32,
598        start_char: 0,
599        end_line: (end_line - 1) as u32,
600        end_char: end_col,
601    };
602    let search_comments = args
603        .get("search_comments")
604        .and_then(Value::as_bool)
605        .unwrap_or(false);
606    let search_text_occurrences = args
607        .get("search_text_occurrences")
608        .and_then(Value::as_bool)
609        .unwrap_or(false);
610
611    let mut backend = match live_jetbrains_backend(project_root) {
612        Ok(b) => b,
613        Err(e) => return format!("ERROR: {e}"),
614    };
615
616    let query = crate::lsp::backend::RenameQuery {
617        abs_path,
618        rel_path,
619        target_range,
620        new_name: new_name.to_string(),
621        search_comments,
622        search_text_occurrences,
623    };
624
625    match action {
626        "rename_preview" => render_rename_preview(backend.as_mut(), project_root, &query, new_name),
627        "rename_apply" => {
628            let expected = args
629                .get("plan_hash")
630                .and_then(Value::as_str)
631                .unwrap_or_default();
632            let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);
633            render_rename_apply(
634                backend.as_mut(),
635                project_root,
636                &query,
637                new_name,
638                expected,
639                force,
640            )
641        }
642        other => format!("ERROR: INTERNAL: not a rename action: {other}"),
643    }
644}
645
646/// Phase 1 renderer for safe_delete: ask Backing B for the REMAINING references
647/// (blocking usages/conflicts), build the stateless plan_hash, present them.
648fn render_safe_delete_preview(
649    backend: &mut dyn crate::lsp::backend::LspBackend,
650    project_root: &str,
651    query: &crate::lsp::backend::SafeDeleteQuery,
652) -> String {
653    let plan = match backend.safe_delete_preview(query) {
654        Ok(p) => p,
655        Err(e) => return format!("ERROR: {e}"),
656    };
657    let hash = match plan_hash(project_root, &plan.usages) {
658        Ok(h) => h,
659        Err(e) => return format!("ERROR: {e}"),
660    };
661    let mut files: Vec<&str> = plan.usages.iter().map(|u| u.path.as_str()).collect();
662    files.sort_unstable();
663    files.dedup();
664    let mut out = format!(
665        "safe_delete_preview: '{}'\n  blocking usages: {}\n  files: {}\n  plan_hash: {hash}\n",
666        query.rel_path,
667        plan.usages.len(),
668        files.len(),
669    );
670    if !plan.conflicts.is_empty() {
671        out.push_str(&format!(
672            "  conflicts: {} (safe_delete_apply blocks unless force=true)\n",
673            plan.conflicts.len()
674        ));
675        for c in &plan.conflicts {
676            out.push_str(&format!("    {}: {}\n", c.path, c.message));
677        }
678    }
679    for f in &files {
680        let n = plan.usages.iter().filter(|u| u.path == **f).count();
681        out.push_str(&format!("  {f}: {n} remaining ref(s)\n"));
682    }
683    out
684}
685
686/// Phase 2 renderer for safe_delete: re-fetch usages, enforce plan_hash (TOCTOU)
687/// and a conflict gate (conflict = "reference still exists", spec §5.4) in Rust,
688/// then run the IDE delete transaction and evict changed files.
689fn render_safe_delete_apply(
690    backend: &mut dyn crate::lsp::backend::LspBackend,
691    project_root: &str,
692    query: &crate::lsp::backend::SafeDeleteQuery,
693    expected_hash: &str,
694    force: bool,
695    propagate: bool,
696) -> String {
697    let plan = match backend.safe_delete_preview(query) {
698        Ok(p) => p,
699        Err(e) => return format!("ERROR: {e}"),
700    };
701    // Gate (a): TOCTOU plan_hash (also jail-checks every usage path).
702    let actual = match plan_hash(project_root, &plan.usages) {
703        Ok(h) => h,
704        Err(e) => return format!("ERROR: {e}"),
705    };
706    if actual != expected_hash {
707        return format!(
708            "ERROR: CONFLICT: plan_hash mismatch (source changed since preview; \
709             expected={expected_hash}, actual={actual})"
710        );
711    }
712    // Gate (b): remaining references block unless force.
713    if !plan.conflicts.is_empty() && !force {
714        return format!(
715            "ERROR: CONFLICT: {} blocking reference(s) remain; pass force=true to delete anyway",
716            plan.conflicts.len()
717        );
718    }
719
720    let apply = crate::lsp::backend::SafeDeleteApply {
721        query: query.clone(),
722        force,
723        propagate,
724    };
725    let res = match backend.safe_delete_apply(&apply) {
726        Ok(r) => r,
727        Err(e) => return format!("ERROR: {e}"),
728    };
729
730    // Jail-check + cache-evict each changed file (Multi-File coherence, spec §9).
731    for cp in &res.changed_paths {
732        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
733            Ok(abs) => crate::core::cli_cache::invalidate(&abs),
734            Err(e) => return format!("ERROR: CONFLICT: changed path blocked by jail: {e}"),
735        }
736    }
737
738    format!(
739        "safe_delete_apply: '{}' deleted\n  changed files: {}\n",
740        query.rel_path,
741        res.changed_paths.len(),
742    )
743}
744
745/// Entry for the Two-Phase safe_delete actions. Resolves the source (name_path /
746/// position), jail-checks it, requires a live IDE, then dispatches to the renderer.
747/// Two-stage jail only (source + changed_paths) — no new caller-supplied target.
748fn handle_safe_delete_refactor(action: &str, args: &Value, project_root: &str) -> String {
749    if action == "safe_delete_apply" && args.get("plan_hash").and_then(Value::as_str).is_none() {
750        return "ERROR: 'plan_hash' is required for safe_delete_apply (run safe_delete_preview first)."
751            .to_string();
752    }
753    // Resolve source symbol → 1-based inclusive span (reuse v2b resolver).
754    let (rel_path, start_line, end_line) = match resolve_rename_target(args, project_root) {
755        Ok(t) => t,
756        Err(e) => return format!("ERROR: {e}"),
757    };
758    let abs_path =
759        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
760            Ok(p) => p,
761            Err(e) => return format!("ERROR: path blocked by jail: {e}"),
762        };
763    // #475: safe_delete_apply removes code from the file; preview only reads.
764    if action == "safe_delete_apply"
765        && let Some(e) = deny_if_read_only(&abs_path)
766    {
767        return e;
768    }
769    let content = match std::fs::read_to_string(&abs_path) {
770        Ok(c) => c,
771        Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
772    };
773    let end_col = content
774        .lines()
775        .nth(end_line.saturating_sub(1))
776        .map_or(0, str::len) as u32;
777    let src_range = crate::lsp::backend::TextRange0Based {
778        start_line: (start_line - 1) as u32,
779        start_char: 0,
780        end_line: (end_line - 1) as u32,
781        end_char: end_col,
782    };
783
784    let mut backend = match live_jetbrains_backend(project_root) {
785        Ok(b) => b,
786        Err(e) => return format!("ERROR: {e}"),
787    };
788
789    let query = crate::lsp::backend::SafeDeleteQuery {
790        abs_path,
791        rel_path,
792        src_range,
793    };
794
795    match action {
796        "safe_delete_preview" => render_safe_delete_preview(backend.as_mut(), project_root, &query),
797        "safe_delete_apply" => {
798            let expected = args
799                .get("plan_hash")
800                .and_then(Value::as_str)
801                .unwrap_or_default();
802            let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);
803            let propagate = args
804                .get("propagate")
805                .and_then(Value::as_bool)
806                .unwrap_or(false);
807            render_safe_delete_apply(
808                backend.as_mut(),
809                project_root,
810                &query,
811                expected,
812                force,
813                propagate,
814            )
815        }
816        other => format!("ERROR: INTERNAL: not a safe_delete action: {other}"),
817    }
818}
819
820/// Resolve the `move` target (spec §5.3 stage 2): EXACTLY ONE of `target_path` /
821/// `target_parent` must be set. `target_path` → jail-checked dir/file →
822/// MoveTarget::Path. `target_parent` → resolve_name_path → its file → MoveTarget::
823/// Parent. None/both → INVALID_TARGET. Jail violation → INVALID_TARGET. This runs
824/// BEFORE any backend call so an out-of-jail target can never reach the plugin.
825fn resolve_move_target(
826    args: &Value,
827    project_root: &str,
828) -> Result<crate::lsp::backend::MoveTarget, String> {
829    let target_path = args.get("target_path").and_then(Value::as_str);
830    let target_parent = args.get("target_parent").and_then(Value::as_str);
831    match (target_path, target_parent) {
832        (Some(_), Some(_)) | (None, None) => {
833            Err("INVALID_TARGET: set exactly one of 'target_path' or 'target_parent'".to_string())
834        }
835        (Some(tp), None) => {
836            let abs = crate::core::path_resolve::resolve_tool_path(Some(project_root), None, tp)
837                .map_err(|e| format!("INVALID_TARGET: target_path blocked by jail: {e}"))?;
838            Ok(crate::lsp::backend::MoveTarget::Path {
839                abs_path: abs,
840                rel_path: tp.to_string(),
841            })
842        }
843        (None, Some(parent_np)) => {
844            let r = resolve_name_path(parent_np, project_root)?; // NO_SYMBOL / AMBIGUOUS_SYMBOL
845            let abs =
846                crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &r.rel_path)
847                    .map_err(|e| {
848                        format!("INVALID_TARGET: target_parent file blocked by jail: {e}")
849                    })?;
850            let content =
851                std::fs::read_to_string(&abs).map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
852            let end_col = content
853                .lines()
854                .nth(r.end_line.saturating_sub(1))
855                .map_or(0, str::len) as u32;
856            Ok(crate::lsp::backend::MoveTarget::Parent {
857                abs_path: abs,
858                rel_path: r.rel_path,
859                range: crate::lsp::backend::TextRange0Based {
860                    start_line: (r.start_line - 1) as u32,
861                    start_char: 0,
862                    end_line: (r.end_line - 1) as u32,
863                    end_char: end_col,
864                },
865            })
866        }
867    }
868}
869
870/// Phase 1 renderer for move: ask Backing B for usages+conflicts at the new
871/// location, build the stateless plan_hash, present the blast radius.
872fn render_move_preview(
873    backend: &mut dyn crate::lsp::backend::LspBackend,
874    project_root: &str,
875    query: &crate::lsp::backend::MoveQuery,
876) -> String {
877    let plan = match backend.move_preview(query) {
878        Ok(p) => p,
879        Err(e) => return format!("ERROR: {e}"),
880    };
881    let hash = match plan_hash(project_root, &plan.usages) {
882        Ok(h) => h,
883        Err(e) => return format!("ERROR: {e}"),
884    };
885    let target_desc = match &query.target {
886        crate::lsp::backend::MoveTarget::Path { rel_path, .. } => format!("→ {rel_path}"),
887        crate::lsp::backend::MoveTarget::Parent { rel_path, .. } => {
888            format!("→ member of {rel_path}")
889        }
890    };
891    let mut files: Vec<&str> = plan.usages.iter().map(|u| u.path.as_str()).collect();
892    files.push(query.rel_path.as_str());
893    files.sort_unstable();
894    files.dedup();
895    let mut out = format!(
896        "move_preview: '{}' {target_desc}\n  usages: {}\n  files: {}\n  plan_hash: {hash}\n",
897        query.rel_path,
898        plan.usages.len(),
899        files.len(),
900    );
901    if !plan.conflicts.is_empty() {
902        out.push_str(&format!(
903            "  conflicts: {} (move_apply blocks unless force=true)\n",
904            plan.conflicts.len()
905        ));
906        for c in &plan.conflicts {
907            out.push_str(&format!("    {}: {}\n", c.path, c.message));
908        }
909    }
910    out
911}
912
913/// Phase 2 renderer for move: re-fetch usages, enforce plan_hash (TOCTOU) +
914/// conflict gate in Rust, run the IDE Multi-File move, then jail-check + evict
915/// every changed path (spec §5.3 stage 3 — includes the NEW destination file).
916fn render_move_apply(
917    backend: &mut dyn crate::lsp::backend::LspBackend,
918    project_root: &str,
919    query: &crate::lsp::backend::MoveQuery,
920    expected_hash: &str,
921    force: bool,
922) -> String {
923    let plan = match backend.move_preview(query) {
924        Ok(p) => p,
925        Err(e) => return format!("ERROR: {e}"),
926    };
927    let actual = match plan_hash(project_root, &plan.usages) {
928        Ok(h) => h,
929        Err(e) => return format!("ERROR: {e}"),
930    };
931    if actual != expected_hash {
932        return format!(
933            "ERROR: CONFLICT: plan_hash mismatch (source changed since preview; \
934             expected={expected_hash}, actual={actual})"
935        );
936    }
937    if !plan.conflicts.is_empty() && !force {
938        return format!(
939            "ERROR: CONFLICT: {} refactoring conflict(s); pass force=true to override",
940            plan.conflicts.len()
941        );
942    }
943
944    let apply = crate::lsp::backend::MoveApply {
945        query: query.clone(),
946        force,
947    };
948    let res = match backend.move_apply(&apply) {
949        Ok(r) => r,
950        Err(e) => return format!("ERROR: {e}"),
951    };
952
953    // Stage-3 jail: every changed path (incl. the new destination file) re-checked
954    // against project_root BEFORE eviction (spec §5.3).
955    for cp in &res.changed_paths {
956        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
957            Ok(abs) => crate::core::cli_cache::invalidate(&abs),
958            Err(e) => return format!("ERROR: CONFLICT: changed path blocked by jail: {e}"),
959        }
960    }
961
962    format!(
963        "move_apply: '{}' applied\n  changed files: {}\n",
964        query.rel_path,
965        res.changed_paths.len(),
966    )
967}
968
969/// Entry for the Two-Phase move actions. Resolves the source (stage-1 jail), the
970/// target (stage-2 jail via resolve_move_target → INVALID_TARGET on miss/escape),
971/// requires a live IDE, then dispatches. Stage-3 jail is inside render_move_apply.
972fn handle_move_refactor(action: &str, args: &Value, project_root: &str) -> String {
973    if action == "move_apply" && args.get("plan_hash").and_then(Value::as_str).is_none() {
974        return "ERROR: 'plan_hash' is required for move_apply (run move_preview first)."
975            .to_string();
976    }
977    // Stage 2 (target) BEFORE any read/backend work, so INVALID_TARGET fires first.
978    let target = match resolve_move_target(args, project_root) {
979        Ok(t) => t,
980        Err(e) => return format!("ERROR: {e}"),
981    };
982    let (rel_path, start_line, end_line) = match resolve_rename_target(args, project_root) {
983        Ok(t) => t,
984        Err(e) => return format!("ERROR: {e}"),
985    };
986    let abs_path =
987        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
988            Ok(p) => p,
989            Err(e) => return format!("ERROR: path blocked by jail: {e}"),
990        };
991    // #475: move_apply edits the source and writes the destination; deny if
992    // EITHER end sits inside a read-only root (preview only reads).
993    if action == "move_apply" {
994        let dest_abs = match &target {
995            crate::lsp::backend::MoveTarget::Path { abs_path, .. }
996            | crate::lsp::backend::MoveTarget::Parent { abs_path, .. } => abs_path.as_str(),
997        };
998        if let Some(e) = deny_if_read_only(&abs_path).or_else(|| deny_if_read_only(dest_abs)) {
999            return e;
1000        }
1001    }
1002    let content = match std::fs::read_to_string(&abs_path) {
1003        Ok(c) => c,
1004        Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
1005    };
1006    let end_col = content
1007        .lines()
1008        .nth(end_line.saturating_sub(1))
1009        .map_or(0, str::len) as u32;
1010    let src_range = crate::lsp::backend::TextRange0Based {
1011        start_line: (start_line - 1) as u32,
1012        start_char: 0,
1013        end_line: (end_line - 1) as u32,
1014        end_char: end_col,
1015    };
1016
1017    let mut backend = match live_jetbrains_backend(project_root) {
1018        Ok(b) => b,
1019        Err(e) => return format!("ERROR: {e}"),
1020    };
1021
1022    let query = crate::lsp::backend::MoveQuery {
1023        abs_path,
1024        rel_path,
1025        src_range,
1026        target,
1027    };
1028
1029    match action {
1030        "move_preview" => render_move_preview(backend.as_mut(), project_root, &query),
1031        "move_apply" => {
1032            let expected = args
1033                .get("plan_hash")
1034                .and_then(Value::as_str)
1035                .unwrap_or_default();
1036            let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);
1037            render_move_apply(backend.as_mut(), project_root, &query, expected, force)
1038        }
1039        other => format!("ERROR: INTERNAL: not a move action: {other}"),
1040    }
1041}
1042
1043/// Phase 1 renderer for inline: ask Backing B for substitution sites + conflicts,
1044/// build the stateless plan_hash, present the blast radius.
1045fn render_inline_preview(
1046    backend: &mut dyn crate::lsp::backend::LspBackend,
1047    project_root: &str,
1048    query: &crate::lsp::backend::InlineQuery,
1049) -> String {
1050    let plan = match backend.inline_preview(query) {
1051        Ok(p) => p,
1052        Err(e) => return format!("ERROR: {e}"),
1053    };
1054    let hash = match plan_hash(project_root, &plan.usages) {
1055        Ok(h) => h,
1056        Err(e) => return format!("ERROR: {e}"),
1057    };
1058    let mut files: Vec<&str> = plan.usages.iter().map(|u| u.path.as_str()).collect();
1059    files.push(query.rel_path.as_str());
1060    files.sort_unstable();
1061    files.dedup();
1062    let mut out = format!(
1063        "inline_preview: '{}'\n  usages: {}\n  files: {}\n  plan_hash: {hash}\n",
1064        query.rel_path,
1065        plan.usages.len(),
1066        files.len(),
1067    );
1068    if !plan.conflicts.is_empty() {
1069        out.push_str(&format!(
1070            "  conflicts: {} (inline_apply blocks — no force; hard refusal → UNSUPPORTED)\n",
1071            plan.conflicts.len()
1072        ));
1073        for c in &plan.conflicts {
1074            out.push_str(&format!("    {}: {}\n", c.path, c.message));
1075        }
1076    }
1077    out
1078}
1079
1080/// Phase 2 renderer for inline: re-fetch sites, enforce plan_hash (TOCTOU) and a
1081/// FORCE-LESS conflict gate (spec §5.2, Entscheidung 4) in Rust, run the IDE
1082/// inline transaction, then jail-check + evict every changed path.
1083fn render_inline_apply(
1084    backend: &mut dyn crate::lsp::backend::LspBackend,
1085    project_root: &str,
1086    query: &crate::lsp::backend::InlineQuery,
1087    expected_hash: &str,
1088) -> String {
1089    let plan = match backend.inline_preview(query) {
1090        Ok(p) => p,
1091        Err(e) => return format!("ERROR: {e}"),
1092    };
1093    let actual = match plan_hash(project_root, &plan.usages) {
1094        Ok(h) => h,
1095        Err(e) => return format!("ERROR: {e}"),
1096    };
1097    if actual != expected_hash {
1098        return format!(
1099            "ERROR: CONFLICT: plan_hash mismatch (source changed since preview; \
1100             expected={expected_hash}, actual={actual})"
1101        );
1102    }
1103    // FORCE-LESS gate: any conflict is final (no bypass arg exists, spec §5.2).
1104    if !plan.conflicts.is_empty() {
1105        return format!(
1106            "ERROR: CONFLICT: {} inline conflict(s); inline cannot be forced",
1107            plan.conflicts.len()
1108        );
1109    }
1110
1111    let apply = crate::lsp::backend::InlineApply {
1112        query: query.clone(),
1113    };
1114    let res = match backend.inline_apply(&apply) {
1115        Ok(r) => r,
1116        // Hard refusal from IntelliJ (recursive, multiple returns, override) → UNSUPPORTED.
1117        Err(e) => return format!("ERROR: {e}"),
1118    };
1119
1120    for cp in &res.changed_paths {
1121        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
1122            Ok(abs) => crate::core::cli_cache::invalidate(&abs),
1123            Err(e) => return format!("ERROR: CONFLICT: changed path blocked by jail: {e}"),
1124        }
1125    }
1126
1127    format!(
1128        "inline_apply: '{}' applied\n  changed files: {}\n",
1129        query.rel_path,
1130        res.changed_paths.len(),
1131    )
1132}
1133
1134/// Entry for the Two-Phase inline actions. Resolves the source (name_path /
1135/// position), jail-checks it, requires a live IDE, then dispatches. NO `force`.
1136fn handle_inline_refactor(action: &str, args: &Value, project_root: &str) -> String {
1137    if action == "inline_apply" && args.get("plan_hash").and_then(Value::as_str).is_none() {
1138        return "ERROR: 'plan_hash' is required for inline_apply (run inline_preview first)."
1139            .to_string();
1140    }
1141    let (rel_path, start_line, end_line) = match resolve_rename_target(args, project_root) {
1142        Ok(t) => t,
1143        Err(e) => return format!("ERROR: {e}"),
1144    };
1145    let abs_path =
1146        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
1147            Ok(p) => p,
1148            Err(e) => return format!("ERROR: path blocked by jail: {e}"),
1149        };
1150    // #475: inline_apply rewrites call sites in the file; preview only reads.
1151    if action == "inline_apply"
1152        && let Some(e) = deny_if_read_only(&abs_path)
1153    {
1154        return e;
1155    }
1156    let content = match std::fs::read_to_string(&abs_path) {
1157        Ok(c) => c,
1158        Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
1159    };
1160    let end_col = content
1161        .lines()
1162        .nth(end_line.saturating_sub(1))
1163        .map_or(0, str::len) as u32;
1164    let src_range = crate::lsp::backend::TextRange0Based {
1165        start_line: (start_line - 1) as u32,
1166        start_char: 0,
1167        end_line: (end_line - 1) as u32,
1168        end_char: end_col,
1169    };
1170
1171    let mut backend = match live_jetbrains_backend(project_root) {
1172        Ok(b) => b,
1173        Err(e) => return format!("ERROR: {e}"),
1174    };
1175
1176    let keep_definition = args
1177        .get("keep_definition")
1178        .and_then(Value::as_bool)
1179        .unwrap_or(false);
1180    let query = crate::lsp::backend::InlineQuery {
1181        abs_path,
1182        rel_path,
1183        src_range,
1184        keep_definition,
1185    };
1186
1187    match action {
1188        "inline_preview" => render_inline_preview(backend.as_mut(), project_root, &query),
1189        "inline_apply" => {
1190            let expected = args
1191                .get("plan_hash")
1192                .and_then(Value::as_str)
1193                .unwrap_or_default();
1194            render_inline_apply(backend.as_mut(), project_root, &query, expected)
1195        }
1196        other => format!("ERROR: INTERNAL: not an inline action: {other}"),
1197    }
1198}
1199
1200/// Resolve the reformat address (spec §5.3) to (abs_path, rel_path, scope).
1201/// EXACTLY one address form: name_path → Symbol; path alone → File; path+line
1202/// (+end_line) → Region. None / contradictory → INVALID_TARGET. Jail-checked here.
1203fn resolve_reformat_scope(
1204    args: &Value,
1205    project_root: &str,
1206) -> Result<(String, String, crate::lsp::backend::ReformatScope), String> {
1207    use crate::lsp::backend::{ReformatScope, TextRange0Based};
1208    let name_path = args.get("name_path").and_then(Value::as_str);
1209    let path = args.get("path").and_then(Value::as_str);
1210    let line = args.get("line").and_then(Value::as_u64);
1211
1212    match (name_path, path) {
1213        (Some(_), Some(_)) | (None, None) => {
1214            Err("INVALID_TARGET: set exactly one of 'name_path' or 'path' for reformat".to_string())
1215        }
1216        (Some(np), None) => {
1217            let r = resolve_name_path(np, project_root)?; // NO_SYMBOL / AMBIGUOUS_SYMBOL
1218            let abs =
1219                crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &r.rel_path)
1220                    .map_err(|e| format!("INVALID_TARGET: path blocked by jail: {e}"))?;
1221            let content =
1222                std::fs::read_to_string(&abs).map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
1223            let end_col = content
1224                .lines()
1225                .nth(r.end_line.saturating_sub(1))
1226                .map_or(0, str::len) as u32;
1227            let range = TextRange0Based {
1228                start_line: (r.start_line - 1) as u32,
1229                start_char: 0,
1230                end_line: (r.end_line - 1) as u32,
1231                end_char: end_col,
1232            };
1233            Ok((abs, r.rel_path, ReformatScope::Symbol { range }))
1234        }
1235        (None, Some(p)) => {
1236            let abs = crate::core::path_resolve::resolve_tool_path(Some(project_root), None, p)
1237                .map_err(|e| format!("INVALID_TARGET: path blocked by jail: {e}"))?;
1238            match line {
1239                None => Ok((abs, p.to_string(), ReformatScope::File)),
1240                Some(l) => {
1241                    if l == 0 {
1242                        return Err(
1243                            "INVALID_TARGET: 'line' is 1-based (>=1) for a region reformat"
1244                                .to_string(),
1245                        );
1246                    }
1247                    let end = args.get("end_line").and_then(Value::as_u64).unwrap_or(l);
1248                    let content = std::fs::read_to_string(&abs)
1249                        .map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
1250                    let end_col = content
1251                        .lines()
1252                        .nth((end as usize).saturating_sub(1))
1253                        .map_or(0, str::len) as u32;
1254                    let range = TextRange0Based {
1255                        start_line: (l - 1) as u32,
1256                        start_char: 0,
1257                        end_line: (end - 1) as u32,
1258                        end_char: end_col,
1259                    };
1260                    Ok((abs, p.to_string(), ReformatScope::Region { range }))
1261                }
1262            }
1263        }
1264    }
1265}
1266
1267/// Single-Phase reformat: resolve address → scope, jail, require live IDE, run the
1268/// IDE reformat, then Single-File evict (spec §5.3). No plan_hash, no preview.
1269fn render_reformat(
1270    backend: &mut dyn crate::lsp::backend::LspBackend,
1271    project_root: &str,
1272    query: &crate::lsp::backend::ReformatQuery,
1273) -> String {
1274    let res = match backend.reformat(query) {
1275        Ok(r) => r,
1276        Err(e) => return format!("ERROR: {e}"),
1277    };
1278    for cp in &res.changed_paths {
1279        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, cp) {
1280            Ok(abs) => crate::core::cli_cache::invalidate(&abs),
1281            Err(e) => return format!("ERROR: INVALID_TARGET: changed path blocked by jail: {e}"),
1282        }
1283    }
1284    format!(
1285        "reformat: '{}' applied\n  changed files: {}\n",
1286        query.rel_path,
1287        res.changed_paths.len(),
1288    )
1289}
1290
1291fn handle_reformat_refactor(args: &Value, project_root: &str) -> String {
1292    let (abs_path, rel_path, scope) = match resolve_reformat_scope(args, project_root) {
1293        Ok(t) => t,
1294        Err(e) => return format!("ERROR: {e}"),
1295    };
1296    // #475: reformat rewrites the file; deny inside a read-only root.
1297    if let Some(e) = deny_if_read_only(&abs_path) {
1298        return e;
1299    }
1300    let mut backend = match live_jetbrains_backend(project_root) {
1301        Ok(b) => b,
1302        Err(e) => return format!("ERROR: {e}"),
1303    };
1304    let optimize_imports = args
1305        .get("optimize_imports")
1306        .and_then(Value::as_bool)
1307        .unwrap_or(false);
1308    let query = crate::lsp::backend::ReformatQuery {
1309        abs_path,
1310        rel_path,
1311        scope,
1312        optimize_imports,
1313    };
1314    render_reformat(backend.as_mut(), project_root, &query)
1315}
1316
1317fn parse_direction(args: &Value) -> HierarchyDirection {
1318    match args.get("direction").and_then(Value::as_str) {
1319        Some("subtypes") => HierarchyDirection::Subtypes,
1320        _ => HierarchyDirection::Supertypes,
1321    }
1322}
1323
1324fn handle_type_hierarchy(
1325    args: &Value,
1326    file_path: &str,
1327    project_root: &str,
1328    uri: &lsp_types::Uri,
1329    position: Position,
1330) -> String {
1331    let direction = parse_direction(args);
1332    let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
1333        let tree = backend.type_hierarchy(uri, position, direction)?;
1334        Ok((tree, backend.last_truncation()))
1335    });
1336    match result {
1337        Ok((tree, meta)) => {
1338            let mut out = format_type_hierarchy(&tree);
1339            if matches!(meta, Some(m) if m.truncated) {
1340                out.push_str("\n(truncated — depth/node cap reached)\n");
1341            }
1342            out
1343        }
1344        Err(e) => format!("ERROR: {e}"),
1345    }
1346}
1347
1348fn handle_symbols_overview(file_path: &str, project_root: &str, uri: &lsp_types::Uri) -> String {
1349    let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
1350        let items = backend.symbols_overview(uri)?;
1351        Ok((items, backend.last_truncation()))
1352    });
1353    match result {
1354        Ok((items, meta)) => {
1355            let mut out = format_symbols_overview(&items);
1356            out.push_str(&truncation_note(items.len(), meta));
1357            out
1358        }
1359        Err(e) => format!("ERROR: {e}"),
1360    }
1361}
1362
1363fn handle_symbol_edit(action: &str, args: &Value, project_root: &str) -> String {
1364    let (rel_path, start_line, end_line) = if let Some(np) =
1365        args.get("name_path").and_then(Value::as_str)
1366    {
1367        match resolve_name_path(np, project_root) {
1368            Ok(r) => (r.rel_path, r.start_line, r.end_line),
1369            Err(e) => return format!("ERROR: {e}"),
1370        }
1371    } else {
1372        let Some(path) = args.get("path").and_then(Value::as_str) else {
1373            return "ERROR: provide 'name_path' or 'path'+'line' for symbol edits.".to_string();
1374        };
1375        let line = args.get("line").and_then(Value::as_u64).unwrap_or(0) as usize;
1376        let end = args
1377            .get("end_line")
1378            .and_then(Value::as_u64)
1379            .unwrap_or(line as u64) as usize;
1380        if line == 0 {
1381            return "ERROR: 'line' is required (1-based) when using the path fallback.".to_string();
1382        }
1383        (path.to_string(), line, end)
1384    };
1385
1386    // 2) PathJail on the resolved path (v1 §4.5 seam — critical before writes).
1387    let abs_path =
1388        match crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &rel_path) {
1389            Ok(p) => p,
1390            Err(e) => return format!("ERROR: path blocked by jail: {e}"),
1391        };
1392    // #475: replace/insert symbol edits always write; deny inside a read-only root.
1393    if let Some(e) = deny_if_read_only(&abs_path) {
1394        return e;
1395    }
1396
1397    let content = match std::fs::read_to_string(&abs_path) {
1398        Ok(c) => c,
1399        Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
1400    };
1401
1402    // 3) Build the canonical range + final wire text per action.
1403    let expected_hash = args
1404        .get("expected_hash")
1405        .and_then(Value::as_str)
1406        .map(String::from);
1407    let (range, text) = match action {
1408        "replace_symbol_body" => {
1409            let Some(new_body) = args.get("new_body").and_then(Value::as_str) else {
1410                return "ERROR: 'new_body' is required for replace_symbol_body.".to_string();
1411            };
1412            let end_col = content
1413                .lines()
1414                .nth(end_line.saturating_sub(1))
1415                .map_or(0, str::len) as u32;
1416            (
1417                crate::lsp::backend::TextRange0Based {
1418                    start_line: (start_line - 1) as u32,
1419                    start_char: 0,
1420                    end_line: (end_line - 1) as u32,
1421                    end_char: end_col,
1422                },
1423                new_body.to_string(),
1424            )
1425        }
1426        "insert_before_symbol" | "insert_after_symbol" => {
1427            let Some(t) = args.get("text").and_then(Value::as_str) else {
1428                return format!("ERROR: 'text' is required for {action}.");
1429            };
1430            let indent = anchor_indent(&content, start_line);
1431            let final_text = format!("{}\n", reindent_first_line(t, &indent));
1432            let insert_line = if action == "insert_before_symbol" {
1433                (start_line - 1) as u32
1434            } else {
1435                end_line as u32
1436            };
1437            (
1438                crate::lsp::backend::TextRange0Based {
1439                    start_line: insert_line,
1440                    start_char: 0,
1441                    end_line: insert_line,
1442                    end_char: 0,
1443                },
1444                final_text,
1445            )
1446        }
1447        other => return format!("ERROR: INTERNAL: not an edit action: {other}"),
1448    };
1449
1450    // CONFLICT guard (BLAKE3, same source as headless local_range_write): verify
1451    // expected_hash against the current on-disk range BEFORE dispatch. This makes
1452    // the IDE path enforce CONFLICT identically to the headless path (which also
1453    // re-checks atomically). hash_hex == blake3::hash(...).to_hex().
1454    if let Some(exp) = &expected_hash {
1455        let s =
1456            match crate::lsp::edit_apply::offset_of(&content, range.start_line, range.start_char) {
1457                Ok(o) => o,
1458                Err(e) => return format!("ERROR: {e}"),
1459            };
1460        let e = match crate::lsp::edit_apply::offset_of(&content, range.end_line, range.end_char) {
1461            Ok(o) => o,
1462            Err(e) => return format!("ERROR: {e}"),
1463        };
1464        if e < s {
1465            return "ERROR: POSITION_OUT_OF_RANGE: end before start".to_string();
1466        }
1467        let actual = crate::core::hasher::hash_hex(&content.as_bytes()[s..e]);
1468        if *exp != actual {
1469            return format!(
1470                "ERROR: CONFLICT: range hash mismatch (expected={exp}, actual={actual})"
1471            );
1472        }
1473    }
1474
1475    let edit = crate::lsp::backend::RangeEdit {
1476        abs_path,
1477        rel_path,
1478        range,
1479        text,
1480        expected_hash,
1481    };
1482
1483    // 4) Dispatch (IDE-first, headless fallback) + format.
1484    match apply_symbol_edit(action, project_root, &edit) {
1485        Ok(res) => format_edit_result(action, &res),
1486        Err(e) => format!("ERROR: {e}"),
1487    }
1488}
1489
1490fn format_edit_result(action: &str, res: &crate::lsp::backend::EditResult) -> String {
1491    if !res.applied {
1492        return format!("{action}: not applied.");
1493    }
1494    let r = res.new_range;
1495    let body = if res.diff.is_empty() {
1496        res.edited_text.clone()
1497    } else {
1498        res.diff.clone()
1499    };
1500    format!(
1501        "{action} applied (L{}:{}-L{}:{}):\n{}",
1502        r.start_line + 1,
1503        r.start_char,
1504        r.end_line + 1,
1505        r.end_char,
1506        body
1507    )
1508}
1509
1510fn handle_inspections(
1511    args: &Value,
1512    file_path: &str,
1513    project_root: &str,
1514    uri: &lsp_types::Uri,
1515) -> String {
1516    let mode = args.get("mode").and_then(Value::as_str).unwrap_or("run");
1517    match mode {
1518        "run" => {
1519            let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
1520                let diags = backend.inspections(uri)?;
1521                Ok((diags, backend.last_truncation()))
1522            });
1523            match result {
1524                Ok((diags, meta)) => {
1525                    let mut out = format_inspections(&diags);
1526                    out.push_str(&truncation_note(diags.len(), meta));
1527                    out
1528                }
1529                Err(e) => format!("ERROR: {e}"),
1530            }
1531        }
1532        "list" => {
1533            let result = crate::lsp::router::with_backend(file_path, project_root, |backend, _| {
1534                let items = backend.list_inspections()?;
1535                Ok((items, backend.last_truncation()))
1536            });
1537            match result {
1538                Ok((items, meta)) => {
1539                    let mut out = format_inspection_list(&items);
1540                    out.push_str(&truncation_note(items.len(), meta));
1541                    out
1542                }
1543                Err(e) => format!("ERROR: {e}"),
1544            }
1545        }
1546        other => format!("ERROR: Unknown mode '{other}' for inspections. Available: run, list."),
1547    }
1548}
1549
1550fn format_inspections(diags: &[InspectionDiag]) -> String {
1551    if diags.is_empty() {
1552        return "No inspection findings.".to_string();
1553    }
1554    let mut out = format!("{} finding(s):\n", diags.len());
1555    for d in diags {
1556        out.push_str(&format!(
1557            "  {}:{}  {}  {}\n",
1558            d.path, d.line, d.severity, d.message
1559        ));
1560    }
1561    out
1562}
1563
1564fn format_inspection_list(items: &[InspectionInfo]) -> String {
1565    if items.is_empty() {
1566        return "No inspections enabled.".to_string();
1567    }
1568    let mut out = format!("{} inspection(s):\n", items.len());
1569    for i in items {
1570        out.push_str(&format!("  {}  {}  {}\n", i.id, i.name, i.severity));
1571    }
1572    out
1573}
1574
1575fn truncation_note(shown: usize, meta: Option<crate::lsp::backend::Truncation>) -> String {
1576    match meta {
1577        Some(m) if m.truncated => {
1578            format!("\n(truncated — showing {shown} of {})\n", m.total)
1579        }
1580        _ => String::new(),
1581    }
1582}
1583
1584fn format_type_hierarchy(root: &TypeHierarchyNode) -> String {
1585    fn walk(node: &TypeHierarchyNode, depth: usize, out: &mut String) {
1586        let indent = "  ".repeat(depth);
1587        out.push_str(&format!(
1588            "{indent}{} ({}:{})\n",
1589            node.name, node.path, node.line
1590        ));
1591        for child in &node.children {
1592            walk(child, depth + 1, out);
1593        }
1594    }
1595    let mut out = String::new();
1596    walk(root, 0, &mut out);
1597    out
1598}
1599
1600fn format_symbols_overview(items: &[SymbolOverviewItem]) -> String {
1601    if items.is_empty() {
1602        return "No symbols found.".to_string();
1603    }
1604    let mut out = format!("{} symbol(s):\n", items.len());
1605    for item in items {
1606        out.push_str(&format!(
1607            "  {} {} (line {})\n",
1608            item.kind, item.name, item.line
1609        ));
1610    }
1611    out
1612}
1613
1614fn format_locations(locations: &[Location], project_root: &str) -> String {
1615    if locations.is_empty() {
1616        return "No results found.".to_string();
1617    }
1618
1619    let mut out = format!("{} location(s):\n", locations.len());
1620    for loc in locations {
1621        let path = uri_to_file_path(&loc.uri).map_or_else(
1622            || loc.uri.as_str().to_string(),
1623            |p| {
1624                p.strip_prefix(project_root)
1625                    .map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
1626                    .unwrap_or(p)
1627            },
1628        );
1629
1630        let line = loc.range.start.line + 1;
1631        let col = loc.range.start.character;
1632        out.push_str(&format!("  {path}:{line}:{col}\n"));
1633    }
1634    out
1635}
1636
1637fn format_workspace_edit(edit: &lsp_types::WorkspaceEdit, project_root: &str) -> String {
1638    let mut out = String::from("Rename edits:\n");
1639    let mut file_count = 0;
1640    let mut edit_count = 0;
1641
1642    if let Some(ref changes) = edit.changes {
1643        for (uri, edits) in changes {
1644            let path = uri_to_file_path(uri).map_or_else(
1645                || uri.as_str().to_string(),
1646                |p| {
1647                    p.strip_prefix(project_root)
1648                        .map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
1649                        .unwrap_or(p)
1650                },
1651            );
1652
1653            file_count += 1;
1654            out.push_str(&format!("  {path}: {} edit(s)\n", edits.len()));
1655            for e in edits {
1656                edit_count += 1;
1657                let line = e.range.start.line + 1;
1658                out.push_str(&format!("    L{line}: -> \"{}\"\n", e.new_text));
1659            }
1660        }
1661    }
1662
1663    if let Some(ref doc_changes) = edit.document_changes {
1664        match doc_changes {
1665            lsp_types::DocumentChanges::Edits(edits) => {
1666                for text_edit in edits {
1667                    let path = uri_to_file_path(&text_edit.text_document.uri)
1668                        .unwrap_or_else(|| text_edit.text_document.uri.as_str().to_string());
1669                    file_count += 1;
1670                    let edits_len = text_edit.edits.len();
1671                    edit_count += edits_len;
1672                    out.push_str(&format!("  {path}: {edits_len} edit(s)\n"));
1673                }
1674            }
1675            lsp_types::DocumentChanges::Operations(ops) => {
1676                for op in ops {
1677                    if let lsp_types::DocumentChangeOperation::Edit(text_edit) = op {
1678                        let path = uri_to_file_path(&text_edit.text_document.uri)
1679                            .unwrap_or_else(|| text_edit.text_document.uri.as_str().to_string());
1680                        file_count += 1;
1681                        let edits_len = text_edit.edits.len();
1682                        edit_count += edits_len;
1683                        out.push_str(&format!("  {path}: {edits_len} edit(s)\n"));
1684                    }
1685                }
1686            }
1687        }
1688    }
1689
1690    out.push_str(&format!(
1691        "\nTotal: {edit_count} edit(s) across {file_count} file(s)."
1692    ));
1693    out
1694}
1695
1696#[cfg(test)]
1697#[path = "ctx_refactor_tests.rs"]
1698mod tests;