Skip to main content

lean_ctx_server/tools/ctx_refactor/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2use lsp_types::{Location, Position};
3use serde_json::Value;
4
5use lean_ctx::lsp::client::uri_to_file_path;
6
7pub fn handle(args: &Value, project_root: &str, abs_path: &str) -> String {
8    let action = args
9        .get("action")
10        .and_then(Value::as_str)
11        .unwrap_or("references");
12
13    if matches!(
14        action,
15        "replace_symbol_body" | "insert_before_symbol" | "insert_after_symbol"
16    ) {
17        return handle_symbol_edit(action, args, project_root);
18    }
19
20    if matches!(action, "rename_preview" | "rename_apply") {
21        return handle_rename_refactor(action, args, project_root);
22    }
23
24    if matches!(action, "safe_delete_preview" | "safe_delete_apply") {
25        return handle_safe_delete_refactor(action, args, project_root);
26    }
27
28    if matches!(action, "move_preview" | "move_apply") {
29        return handle_move_refactor(action, args, project_root);
30    }
31
32    if matches!(action, "inline_preview" | "inline_apply") {
33        return handle_inline_refactor(action, args, project_root);
34    }
35
36    if action == "reformat" {
37        return handle_reformat_refactor(args, project_root);
38    }
39
40    let line = args.get("line").and_then(Value::as_u64).unwrap_or(1) as u32;
41    let column = args.get("column").and_then(Value::as_u64).unwrap_or(0) as u32;
42    let scope = args
43        .get("scope")
44        .and_then(Value::as_str)
45        .unwrap_or("project");
46
47    let uri = match lean_ctx::lsp::router::open_file(abs_path, project_root) {
48        Ok(u) => u,
49        Err(e) => return format!("ERROR: {e}"),
50    };
51
52    let position = Position::new(line.saturating_sub(1), column);
53
54    // #475: the IDE symbol `rename` rewrites the target file in place; deny when
55    // it sits inside a read-only root (the other read actions below never write).
56    if action == "rename"
57        && let Some(e) = deny_if_read_only(abs_path)
58    {
59        return e;
60    }
61
62    match action {
63        "rename" => handle_rename(args, abs_path, project_root, &uri, position),
64        "references" => handle_references(abs_path, project_root, &uri, position, scope),
65        "definition" => handle_definition(abs_path, project_root, &uri, position),
66        "implementations" => handle_implementations(abs_path, project_root, &uri, position, scope),
67        "declaration" => handle_declaration(abs_path, project_root, &uri, position),
68        "type_hierarchy" => handle_type_hierarchy(args, abs_path, project_root, &uri, position),
69        "symbols_overview" => handle_symbols_overview(abs_path, project_root, &uri),
70        "inspections" => handle_inspections(args, abs_path, project_root, &uri),
71        _ => format!(
72            "ERROR: Unknown action '{action}'. Available: rename, references, definition, \
73             implementations, declaration, type_hierarchy, symbols_overview, inspections, \
74             replace_symbol_body, insert_before_symbol, insert_after_symbol, \
75             rename_preview, rename_apply, safe_delete_preview, safe_delete_apply, \
76             move_preview, move_apply, inline_preview, inline_apply, reformat."
77        ),
78    }
79}
80
81/// #475 read-only-roots default-deny for refactor writes. Returns an early
82/// `ERROR: …` string when `abs_path` resolves inside a configured read-only
83/// root, `None` otherwise. Two-phase `*_preview` actions only read and are
84/// never gated; every apply / symbol-edit / reformat / rename path routes its
85/// resolved target(s) through this before any IDE or headless write.
86fn deny_if_read_only(abs_path: &str) -> Option<String> {
87    lean_ctx::core::pathjail::enforce_writable(std::path::Path::new(abs_path))
88        .err()
89        .map(|e| format!("ERROR: {e}"))
90}
91
92fn handle_rename(
93    args: &Value,
94    file_path: &str,
95    project_root: &str,
96    uri: &lsp_types::Uri,
97    position: Position,
98) -> String {
99    let Some(new_name) = args.get("new_name").and_then(Value::as_str) else {
100        return "ERROR: 'new_name' parameter is required for rename.".to_string();
101    };
102
103    let result = lean_ctx::lsp::router::with_backend(file_path, project_root, |backend, _| {
104        backend.rename(uri, position, new_name)
105    });
106
107    match result {
108        Ok(Some(edit)) => format_workspace_edit(&edit, project_root),
109        Ok(None) => "No rename edits returned by language server.".to_string(),
110        Err(e) => format!("ERROR: {e}"),
111    }
112}
113
114fn handle_references(
115    file_path: &str,
116    project_root: &str,
117    uri: &lsp_types::Uri,
118    position: Position,
119    scope: &str,
120) -> String {
121    let result = lean_ctx::lsp::router::with_backend(file_path, project_root, |backend, _| {
122        let locs = backend.references(uri, position, scope)?;
123        Ok((locs, backend.last_truncation()))
124    });
125
126    match result {
127        Ok((locations, meta)) => {
128            let mut out = format_locations(&locations, project_root);
129            out.push_str(&truncation_note(locations.len(), meta));
130            out
131        }
132        Err(e) => format!("ERROR: {e}"),
133    }
134}
135
136fn handle_definition(
137    file_path: &str,
138    project_root: &str,
139    uri: &lsp_types::Uri,
140    position: Position,
141) -> String {
142    let result = lean_ctx::lsp::router::with_backend(file_path, project_root, |backend, _| {
143        backend.definition(uri, position)
144    });
145
146    match result {
147        Ok(resp) => {
148            let locations = match resp {
149                lsp_types::GotoDefinitionResponse::Scalar(loc) => vec![loc],
150                lsp_types::GotoDefinitionResponse::Array(locs) => locs,
151                lsp_types::GotoDefinitionResponse::Link(links) => links
152                    .into_iter()
153                    .map(|l| Location {
154                        uri: l.target_uri,
155                        range: l.target_selection_range,
156                    })
157                    .collect(),
158            };
159            format_locations(&locations, project_root)
160        }
161        Err(e) => format!("ERROR: {e}"),
162    }
163}
164
165fn handle_implementations(
166    file_path: &str,
167    project_root: &str,
168    uri: &lsp_types::Uri,
169    position: Position,
170    scope: &str,
171) -> String {
172    let result = lean_ctx::lsp::router::with_backend(file_path, project_root, |backend, _| {
173        let locs = backend.implementations(uri, position, scope)?;
174        Ok((locs, backend.last_truncation()))
175    });
176
177    match result {
178        Ok((locations, meta)) => {
179            let mut out = format_locations(&locations, project_root);
180            out.push_str(&truncation_note(locations.len(), meta));
181            out
182        }
183        Err(e) => format!("ERROR: {e}"),
184    }
185}
186
187fn handle_declaration(
188    file_path: &str,
189    project_root: &str,
190    uri: &lsp_types::Uri,
191    position: Position,
192) -> String {
193    let result = lean_ctx::lsp::router::with_backend(file_path, project_root, |backend, _| {
194        backend.declaration(uri, position)
195    });
196
197    match result {
198        Ok(locations) => format_locations(&locations, project_root),
199        Err(e) => format!("ERROR: {e}"),
200    }
201}
202
203use lean_ctx::lsp::backend::{
204    HierarchyDirection, InspectionDiag, InspectionInfo, SymbolOverviewItem, TypeHierarchyNode,
205};
206
207/// A resolved symbol location (project-relative path + 1-based inclusive line span).
208#[derive(Debug)]
209pub(crate) struct Resolved {
210    pub rel_path: String,
211    pub start_line: usize,
212    pub end_line: usize,
213}
214
215/// Apply a resolved edit. IDE-first: a live JetBrains backend (port file +
216/// liveness, mirroring router::select_backend) handles it via WriteCommandAction;
217/// otherwise the headless local_range_write applies the identical bytes.
218pub(crate) fn apply_symbol_edit(
219    action: &str,
220    project_root: &str,
221    edit: &lean_ctx::lsp::backend::RangeEdit,
222) -> Result<lean_ctx::lsp::backend::EditResult, String> {
223    use lean_ctx::lsp::backend::LspBackend;
224    use lean_ctx::lsp::port_discovery;
225
226    let mut backend: Box<dyn LspBackend> =
227        if let Some(pf) = port_discovery::read_port_file(project_root) {
228            if port_discovery::pid_alive(pf.pid) && port_discovery::health_ok(&pf) {
229                Box::new(lean_ctx::lsp::jetbrains_backend::JetBrainsHttpBackend::new(
230                    pf.port,
231                    pf.token,
232                    project_root.to_string(),
233                    pf.pid,
234                ))
235            } else {
236                Box::new(lean_ctx::lsp::edit_apply::HeadlessBackend)
237            }
238        } else {
239            Box::new(lean_ctx::lsp::edit_apply::HeadlessBackend)
240        };
241
242    match action {
243        "replace_symbol_body" => backend.replace_symbol_body(edit),
244        "insert_before_symbol" => backend.insert_before_symbol(edit),
245        "insert_after_symbol" => backend.insert_after_symbol(edit),
246        other => Err(format!("INTERNAL: not an edit action: {other}")),
247    }
248}
249
250/// Leading whitespace of the 1-based `line` in `content` (anchor indentation).
251pub(crate) fn anchor_indent(content: &str, line: usize) -> String {
252    content
253        .lines()
254        .nth(line.saturating_sub(1))
255        .map(|l| l.chars().take_while(|c| *c == ' ' || *c == '\t').collect())
256        .unwrap_or_default()
257}
258
259/// Prefix `indent` to the first line of `text` iff that line has no leading
260/// whitespace of its own (deterministic; the same Rust computes it for both
261/// apply paths, so the wire text is byte-identical).
262pub(crate) fn reindent_first_line(text: &str, indent: &str) -> String {
263    if text.starts_with(' ') || text.starts_with('\t') || indent.is_empty() {
264        return text.to_string();
265    }
266    format!("{indent}{text}")
267}
268
269/// True if symbol `name` denotes a container for type `ancestor`: the bare type
270/// itself (struct/enum/inherent `impl Type`) — exact match — or a trait impl,
271/// whose indexed name is `<Trait> for <Type>` (see the round-trip note in
272/// graph_provider.rs). Generic args on the impl target (`… for Type<T>`) are
273/// stripped so `Type/method` still resolves. Language-agnostic: non-Rust
274/// container names never contain `" for "`, so only the exact branch applies.
275fn container_matches_ancestor(name: &str, ancestor: &str) -> bool {
276    if name == ancestor {
277        return true;
278    }
279    match name.rsplit_once(" for ") {
280        Some((_, target)) => target.split('<').next().unwrap_or(target).trim() == ancestor,
281        None => false,
282    }
283}
284
285/// Resolve a `name_path` (`Class/method` or bare `name`) to a single symbol via
286/// the tree-sitter index (spec v2a §3/§5.3). Disambiguates a qualified path by
287/// enclosing-range containment (ancestor symbol's line span contains the leaf's).
288/// #845: optional `file_scope` narrows candidates to a single file, preventing
289/// AMBIGUOUS_SYMBOL for common names like `Dispose` that appear repo-wide.
290pub(crate) fn resolve_name_path(name_path: &str, project_root: &str) -> Result<Resolved, String> {
291    resolve_name_path_scoped(name_path, project_root, None)
292}
293
294pub(crate) fn resolve_name_path_scoped(
295    name_path: &str,
296    project_root: &str,
297    file_scope: Option<&str>,
298) -> Result<Resolved, String> {
299    use lean_ctx::core::graph_provider;
300    let open = graph_provider::open_or_build(project_root)
301        .ok_or_else(|| "NO_SYMBOL: no symbol index available".to_string())?;
302    let gp = &open.provider;
303
304    let segments: Vec<&str> = name_path.split('/').filter(|s| !s.is_empty()).collect();
305    let leaf = *segments
306        .last()
307        .ok_or_else(|| "NO_SYMBOL: empty name_path".to_string())?;
308
309    // Exact-name leaf candidates (case-sensitive — the index may substring-match).
310    let mut leaves: Vec<_> = gp
311        .find_symbols(leaf, None, None)
312        .into_iter()
313        .filter(|s| s.name == leaf)
314        .collect();
315
316    // #845: narrow by file when the caller provides a scope.
317    if let Some(scope) = file_scope {
318        leaves.retain(|s| s.file.ends_with(scope) || s.file == scope);
319    }
320
321    if segments.len() >= 2 {
322        let ancestor = segments[segments.len() - 2];
323        let parents: Vec<_> = gp
324            .find_symbols(ancestor, None, None)
325            .into_iter()
326            .filter(|s| container_matches_ancestor(&s.name, ancestor))
327            .collect();
328        leaves.retain(|leaf_sym| {
329            parents.iter().any(|p| {
330                p.file == leaf_sym.file
331                    && p.start_line <= leaf_sym.start_line
332                    && leaf_sym.end_line <= p.end_line
333            })
334        });
335    }
336
337    match leaves.len() {
338        0 => Err(format!(
339            "NO_SYMBOL: '{name_path}' did not resolve to any indexed symbol. \
340             A file created since the index was built is not in it yet — run \
341             ctx_search(action=reindex), pass 'path' so the file can be parsed \
342             directly, or target the span with 'path'+'line'."
343        )),
344        1 => Ok(Resolved {
345            rel_path: leaves[0].file.clone(),
346            start_line: leaves[0].start_line,
347            end_line: leaves[0].end_line,
348        }),
349        _ => {
350            let mut msg = format!(
351                "AMBIGUOUS_SYMBOL: '{name_path}' matches {} symbols; qualify it:\n",
352                leaves.len()
353            );
354            for s in leaves.iter().take(10) {
355                msg.push_str(&format!(
356                    "  {}:{} (L{}-{})\n",
357                    s.file, s.name, s.start_line, s.end_line
358                ));
359            }
360            Err(msg)
361        }
362    }
363}
364
365/// Resolve a `name_path` by parsing the file the caller named, instead of
366/// consulting the symbol index (#1593).
367///
368/// The index is a repo scan; a file created during the session is not in it, so
369/// `replace_symbol` on a just-written file failed with `NO_SYMBOL` even though
370/// `ctx_read(mode=signatures)` had listed the symbol seconds earlier. When the
371/// caller names the file, the file itself is authority enough — same tree-sitter
372/// extractor the signature view uses, so the two can no longer disagree.
373/// The ORIGINAL text of a refactor target, for a request that may read it.
374///
375/// Refactors compute LSP positions, plan hashes and edits, so they need the
376/// bytes that are on disk — an admitted (possibly redacted) text would shift
377/// columns, and nothing redacted may ever be written back. What admission
378/// decides here (G01) is whether the running request may operate on the
379/// source at all: the read path is asked first, and a source it withholds is
380/// not read for a preview, a hash or an edit. Inside a request scope that is
381/// the project policy and the gateway; outside one the read path is a plain
382/// local read, as for every other reader. The returned text is never rendered
383/// by this module — it feeds positions, digests and the edit engine only.
384pub(crate) fn read_original_if_admitted(abs_path: &str) -> std::io::Result<String> {
385    if let Err(error) = crate::tools::ctx_read::read_file_lossy(abs_path)
386        && error.kind() == std::io::ErrorKind::PermissionDenied
387    {
388        return Err(error);
389    }
390    std::fs::read_to_string(abs_path)
391}
392
393pub(crate) fn resolve_name_path_in_file(
394    name_path: &str,
395    project_root: &str,
396    path: &str,
397) -> Result<Resolved, String> {
398    let abs = lean_ctx::core::path_resolve::resolve_tool_path(Some(project_root), None, path)
399        .map_err(|e| format!("NO_SYMBOL: path blocked by jail: {e}"))?;
400    let content = read_original_if_admitted(&abs)
401        .map_err(|e| format!("NO_SYMBOL: cannot read '{path}': {e}"))?;
402    let ext = std::path::Path::new(&abs)
403        .extension()
404        .and_then(|e| e.to_str())
405        .unwrap_or("");
406    let leaf = name_path
407        .split(['/', ':'])
408        .rfind(|s| !s.is_empty())
409        .ok_or_else(|| "NO_SYMBOL: empty name_path".to_string())?;
410
411    let mut hits = lean_ctx::core::signatures::extract_signatures(&content, ext)
412        .into_iter()
413        .filter(|s| s.name == leaf)
414        .filter_map(|s| Some((s.start_line?, s.end_line?)));
415    let (start_line, end_line) = hits.next().ok_or_else(|| {
416        format!(
417            "NO_SYMBOL: '{name_path}' not found in '{path}' \
418             (parsed the file directly; the symbol index did not have it either)"
419        )
420    })?;
421    if hits.next().is_some() {
422        return Err(format!(
423            "AMBIGUOUS_SYMBOL: '{leaf}' appears more than once in '{path}'; \
424             target the one you mean with 'line'"
425        ));
426    }
427    Ok(Resolved {
428        rel_path: path.to_string(),
429        start_line,
430        end_line,
431    })
432}
433
434/// Read the current on-disk text covered by a usage's range, jail-checking its
435/// path first. Out-of-jail / unreadable / bad range → `Err` (spec §5.4 Multi-File
436/// jail: every plugin-reported path is re-checked against `project_root`).
437pub(crate) fn usage_range_text(
438    project_root: &str,
439    u: &lean_ctx::lsp::backend::UsageSite,
440) -> Result<String, String> {
441    let abs = lean_ctx::core::path_resolve::resolve_tool_path(Some(project_root), None, &u.path)
442        .map_err(|e| format!("CONFLICT: usage path blocked by jail: {e}"))?;
443    let content =
444        read_original_if_admitted(&abs).map_err(|e| format!("FILE_NOT_FOUND: {abs}: {e}"))?;
445    let s = lean_ctx::lsp::edit_apply::offset_of(&content, u.range.start_line, u.range.start_char)?;
446    let e = lean_ctx::lsp::edit_apply::offset_of(&content, u.range.end_line, u.range.end_char)?;
447    if e < s {
448        return Err("POSITION_OUT_OF_RANGE: end before start".to_string());
449    }
450    Ok(content[s..e].to_string())
451}
452
453/// Stateless Multi-File integrity guard (spec §5.2). BLAKE3 over the usages
454/// canonicalized by sorted `(path, range)` plus each usage's *current* on-disk
455/// text. `context` is display-only and intentionally excluded. Re-built in
456/// `rename_apply` and compared → mismatch = `CONFLICT` (TOCTOU).
457pub(crate) fn plan_hash(
458    project_root: &str,
459    usages: &[lean_ctx::lsp::backend::UsageSite],
460) -> Result<String, String> {
461    use lean_ctx::lsp::backend::TextRange0Based;
462    let mut rows: Vec<(String, TextRange0Based, String)> = Vec::with_capacity(usages.len());
463    for u in usages {
464        let text = usage_range_text(project_root, u)?;
465        rows.push((u.path.clone(), u.range, text));
466    }
467    rows.sort_by(|a, b| {
468        a.0.cmp(&b.0)
469            .then(a.1.start_line.cmp(&b.1.start_line))
470            .then(a.1.start_char.cmp(&b.1.start_char))
471            .then(a.1.end_line.cmp(&b.1.end_line))
472            .then(a.1.end_char.cmp(&b.1.end_char))
473    });
474    let mut canon = String::new();
475    for (path, r, text) in &rows {
476        canon.push_str(&format!(
477            "{path}|{}:{}-{}:{}|{text}\n",
478            r.start_line, r.start_char, r.end_line, r.end_char
479        ));
480    }
481    Ok(lean_ctx::core::hasher::hash_hex(canon.as_bytes()))
482}
483
484mod ops;
485#[allow(clippy::wildcard_imports)]
486use ops::*;
487
488fn parse_direction(args: &Value) -> HierarchyDirection {
489    match args.get("direction").and_then(Value::as_str) {
490        Some("subtypes") => HierarchyDirection::Subtypes,
491        _ => HierarchyDirection::Supertypes,
492    }
493}
494
495fn handle_type_hierarchy(
496    args: &Value,
497    file_path: &str,
498    project_root: &str,
499    uri: &lsp_types::Uri,
500    position: Position,
501) -> String {
502    let direction = parse_direction(args);
503    let result = lean_ctx::lsp::router::with_backend(file_path, project_root, |backend, _| {
504        let tree = backend.type_hierarchy(uri, position, direction)?;
505        Ok((tree, backend.last_truncation()))
506    });
507    match result {
508        // No type at that position (or the server is still indexing).
509        Ok((tree, _)) if tree.name.is_empty() => "No type at this position.".to_string(),
510        Ok((tree, meta)) => {
511            let mut out = format_type_hierarchy(&tree);
512            if matches!(meta, Some(m) if m.truncated) {
513                out.push_str("\n(truncated — depth/node cap reached)\n");
514            }
515            out
516        }
517        Err(e) => format!("ERROR: {e}"),
518    }
519}
520
521fn handle_symbols_overview(file_path: &str, project_root: &str, uri: &lsp_types::Uri) -> String {
522    let result = lean_ctx::lsp::router::with_backend(file_path, project_root, |backend, _| {
523        let items = backend.symbols_overview(uri)?;
524        Ok((items, backend.last_truncation()))
525    });
526    match result {
527        Ok((items, meta)) => {
528            let mut out = format_symbols_overview(&items);
529            out.push_str(&truncation_note(items.len(), meta));
530            out
531        }
532        Err(e) => format!("ERROR: {e}"),
533    }
534}
535
536fn handle_symbol_edit(action: &str, args: &Value, project_root: &str) -> String {
537    let explicit_path = args.get("path").and_then(Value::as_str);
538
539    let (rel_path, start_line, end_line) = if let Some(np) =
540        args.get("name_path").and_then(Value::as_str)
541    {
542        // #1593: the index is a repo scan and misses files created this session.
543        // When the caller named the file, fall back to parsing it directly.
544        let resolved = match (resolve_name_path(np, project_root), explicit_path) {
545            (Err(e), Some(p)) if e.starts_with("NO_SYMBOL") => {
546                resolve_name_path_in_file(np, project_root, p)
547            }
548            (other, _) => other,
549        };
550        match resolved {
551            Ok(r) => {
552                // #803 WORKTREE SAFETY: when the caller also provided `path`,
553                // verify the index-resolved file matches the caller's target.
554                // In git worktrees the index may point to the main checkout
555                // while the caller works in a linked worktree — writing to the
556                // wrong root causes silent data loss.
557                if let Some(caller_path) = explicit_path {
558                    let resolved_abs = match lean_ctx::core::path_resolve::resolve_tool_path(
559                        Some(project_root),
560                        None,
561                        &r.rel_path,
562                    ) {
563                        Ok(p) => p,
564                        Err(e) => return format!("ERROR: path blocked by jail: {e}"),
565                    };
566                    let caller_abs = match lean_ctx::core::path_resolve::resolve_tool_path(
567                        Some(project_root),
568                        None,
569                        caller_path,
570                    ) {
571                        Ok(p) => p,
572                        Err(e) => return format!("ERROR: path blocked by jail: {e}"),
573                    };
574                    let resolved_real = std::fs::canonicalize(&resolved_abs)
575                        .unwrap_or_else(|_| std::path::PathBuf::from(&resolved_abs));
576                    let caller_real = std::fs::canonicalize(&caller_abs)
577                        .unwrap_or_else(|_| std::path::PathBuf::from(&caller_abs));
578                    if resolved_real != caller_real {
579                        return format!(
580                            "ERROR: WORKTREE_MISMATCH: symbol '{np}' resolved to '{resolved_abs}' \
581                             but caller specified '{caller_abs}'. The symbol index points to a \
582                             different checkout (likely a git worktree mismatch). \
583                             Use op=replace_lines with explicit line range instead, \
584                             or re-index from the correct root.",
585                        );
586                    }
587                    (caller_path.to_string(), r.start_line, r.end_line)
588                } else {
589                    (r.rel_path, r.start_line, r.end_line)
590                }
591            }
592            Err(e) => return format!("ERROR: {e}"),
593        }
594    } else {
595        let Some(path) = explicit_path else {
596            return "ERROR: provide 'name_path' or 'path'+'line' for symbol edits.".to_string();
597        };
598        let line = args.get("line").and_then(Value::as_u64).unwrap_or(0) as usize;
599        let end = args
600            .get("end_line")
601            .and_then(Value::as_u64)
602            .unwrap_or(line as u64) as usize;
603        if line == 0 {
604            return "ERROR: 'line' is required (1-based) when using the path fallback.".to_string();
605        }
606        (path.to_string(), line, end)
607    };
608
609    // 2) PathJail on the resolved path (v1 §4.5 seam — critical before writes).
610    let abs_path = match lean_ctx::core::path_resolve::resolve_tool_path(
611        Some(project_root),
612        None,
613        &rel_path,
614    ) {
615        Ok(p) => p,
616        Err(e) => return format!("ERROR: path blocked by jail: {e}"),
617    };
618    // #475: replace/insert symbol edits always write; deny inside a read-only root.
619    if let Some(e) = deny_if_read_only(&abs_path) {
620        return e;
621    }
622
623    let content = match read_original_if_admitted(&abs_path) {
624        Ok(c) => c,
625        Err(e) => return format!("ERROR: FILE_NOT_FOUND: {abs_path}: {e}"),
626    };
627
628    // 3) Build the canonical range + final wire text per action.
629    let expected_hash = args
630        .get("expected_hash")
631        .and_then(Value::as_str)
632        .map(String::from);
633    let (range, text) = match action {
634        "replace_symbol_body" => {
635            let Some(new_text) = args.get("new_text").and_then(Value::as_str) else {
636                return "ERROR: 'new_text' is required for replace_symbol_body.".to_string();
637            };
638            let end_col = content
639                .lines()
640                .nth(end_line.saturating_sub(1))
641                .map_or(0, str::len) as u32;
642            (
643                lean_ctx::lsp::backend::TextRange0Based {
644                    start_line: (start_line - 1) as u32,
645                    start_char: 0,
646                    end_line: (end_line - 1) as u32,
647                    end_char: end_col,
648                },
649                new_text.to_string(),
650            )
651        }
652        "insert_before_symbol" | "insert_after_symbol" => {
653            let Some(t) = args.get("text").and_then(Value::as_str) else {
654                return format!("ERROR: 'text' is required for {action}.");
655            };
656            let indent = anchor_indent(&content, start_line);
657            let final_text = format!("{}\n", reindent_first_line(t, &indent));
658            let insert_line = if action == "insert_before_symbol" {
659                (start_line - 1) as u32
660            } else {
661                end_line as u32
662            };
663            (
664                lean_ctx::lsp::backend::TextRange0Based {
665                    start_line: insert_line,
666                    start_char: 0,
667                    end_line: insert_line,
668                    end_char: 0,
669                },
670                final_text,
671            )
672        }
673        other => return format!("ERROR: INTERNAL: not an edit action: {other}"),
674    };
675
676    // CONFLICT guard (BLAKE3, same source as headless local_range_write): verify
677    // expected_hash against the current on-disk range BEFORE dispatch. This makes
678    // the IDE path enforce CONFLICT identically to the headless path (which also
679    // re-checks atomically). hash_hex == blake3::hash(...).to_hex().
680    if let Some(exp) = &expected_hash {
681        let s = match lean_ctx::lsp::edit_apply::offset_of(
682            &content,
683            range.start_line,
684            range.start_char,
685        ) {
686            Ok(o) => o,
687            Err(e) => return format!("ERROR: {e}"),
688        };
689        let e = match lean_ctx::lsp::edit_apply::offset_of(&content, range.end_line, range.end_char)
690        {
691            Ok(o) => o,
692            Err(e) => return format!("ERROR: {e}"),
693        };
694        if e < s {
695            return "ERROR: POSITION_OUT_OF_RANGE: end before start".to_string();
696        }
697        let actual = lean_ctx::core::hasher::hash_hex(&content.as_bytes()[s..e]);
698        if *exp != actual {
699            return format!(
700                "ERROR: CONFLICT: range hash mismatch (expected={exp}, actual={actual})"
701            );
702        }
703    }
704
705    let edit = lean_ctx::lsp::backend::RangeEdit {
706        abs_path,
707        rel_path,
708        range,
709        text,
710        expected_hash,
711    };
712
713    // 4) Pre-write syntax gate (#836): reject edits that would break a cleanly
714    //    parsing file. Build the hypothetical new content and check it.
715    let ext = std::path::Path::new(&edit.abs_path)
716        .extension()
717        .and_then(std::ffi::OsStr::to_str)
718        .unwrap_or("");
719    {
720        let start_off =
721            lean_ctx::lsp::edit_apply::offset_of(&content, range.start_line, range.start_char)
722                .unwrap_or(0);
723        let end_off =
724            lean_ctx::lsp::edit_apply::offset_of(&content, range.end_line, range.end_char)
725                .unwrap_or(content.len());
726        let mut hypothetical =
727            String::with_capacity(content.len() - (end_off - start_off) + edit.text.len());
728        hypothetical.push_str(&content[..start_off]);
729        hypothetical.push_str(&edit.text);
730        hypothetical.push_str(&content[end_off..]);
731        if let Some(reason) =
732            lean_ctx::core::syntax_validate::gate_edit(ext, &content, &hypothetical)
733        {
734            return reason;
735        }
736    }
737
738    // 5) Dispatch (IDE-first, headless fallback) + format.
739    match apply_symbol_edit(action, project_root, &edit) {
740        Ok(res) => format_edit_result(action, &edit.abs_path, &res),
741        Err(e) => format!("ERROR: {e}"),
742    }
743}
744
745fn format_edit_result(
746    action: &str,
747    abs_path: &str,
748    res: &lean_ctx::lsp::backend::EditResult,
749) -> String {
750    if !res.applied {
751        return format!("{action}: not applied.");
752    }
753    let r = res.new_range;
754    let body = if res.diff.is_empty() {
755        res.edited_text.clone()
756    } else {
757        res.diff.clone()
758    };
759    // #803: include absolute path so callers can verify the write target
760    format!(
761        "{action} applied {abs_path} (L{}:{}-L{}:{}):\n{}",
762        r.start_line + 1,
763        r.start_char,
764        r.end_line + 1,
765        r.end_char,
766        body
767    )
768}
769
770fn handle_inspections(
771    args: &Value,
772    file_path: &str,
773    project_root: &str,
774    uri: &lsp_types::Uri,
775) -> String {
776    let mode = args.get("mode").and_then(Value::as_str).unwrap_or("run");
777    match mode {
778        "run" => {
779            let result =
780                lean_ctx::lsp::router::with_backend(file_path, project_root, |backend, _| {
781                    let diags = backend.inspections(uri)?;
782                    Ok((diags, backend.last_truncation()))
783                });
784            match result {
785                Ok((diags, meta)) => {
786                    let mut out = format_inspections(&diags);
787                    out.push_str(&truncation_note(diags.len(), meta));
788                    out
789                }
790                Err(e) => format!("ERROR: {e}"),
791            }
792        }
793        "list" => {
794            let result =
795                lean_ctx::lsp::router::with_backend(file_path, project_root, |backend, _| {
796                    let items = backend.list_inspections()?;
797                    Ok((items, backend.last_truncation()))
798                });
799            match result {
800                Ok((items, meta)) => {
801                    let mut out = format_inspection_list(&items);
802                    out.push_str(&truncation_note(items.len(), meta));
803                    out
804                }
805                Err(e) => format!("ERROR: {e}"),
806            }
807        }
808        other => format!("ERROR: Unknown mode '{other}' for inspections. Available: run, list."),
809    }
810}
811
812fn format_inspections(diags: &[InspectionDiag]) -> String {
813    if diags.is_empty() {
814        return "No inspection findings.".to_string();
815    }
816    let mut out = format!("{} finding(s):\n", diags.len());
817    for d in diags {
818        out.push_str(&format!(
819            "  {}:{}  {}  {}\n",
820            d.path, d.line, d.severity, d.message
821        ));
822    }
823    out
824}
825
826fn format_inspection_list(items: &[InspectionInfo]) -> String {
827    if items.is_empty() {
828        return "No inspections enabled.".to_string();
829    }
830    let mut out = format!("{} inspection(s):\n", items.len());
831    for i in items {
832        out.push_str(&format!("  {}  {}  {}\n", i.id, i.name, i.severity));
833    }
834    out
835}
836
837fn truncation_note(shown: usize, meta: Option<lean_ctx::lsp::backend::Truncation>) -> String {
838    match meta {
839        Some(m) if m.truncated => {
840            format!("\n(truncated — showing {shown} of {})\n", m.total)
841        }
842        _ => String::new(),
843    }
844}
845
846fn format_type_hierarchy(root: &TypeHierarchyNode) -> String {
847    fn walk(node: &TypeHierarchyNode, depth: usize, out: &mut String) {
848        let indent = "  ".repeat(depth);
849        out.push_str(&format!(
850            "{indent}{} ({}:{})\n",
851            node.name, node.path, node.line
852        ));
853        for child in &node.children {
854            walk(child, depth + 1, out);
855        }
856    }
857    let mut out = String::new();
858    walk(root, 0, &mut out);
859    out
860}
861
862fn format_symbols_overview(items: &[SymbolOverviewItem]) -> String {
863    if items.is_empty() {
864        return "No symbols found.".to_string();
865    }
866    let mut out = format!("{} symbol(s):\n", items.len());
867    for item in items {
868        out.push_str(&format!(
869            "  {} {} (line {})\n",
870            item.kind, item.name, item.line
871        ));
872    }
873    out
874}
875
876fn format_locations(locations: &[Location], project_root: &str) -> String {
877    if locations.is_empty() {
878        return "No results found.".to_string();
879    }
880
881    let mut out = format!("{} location(s):\n", locations.len());
882    for loc in locations {
883        let path = uri_to_file_path(&loc.uri).map_or_else(
884            || loc.uri.as_str().to_string(),
885            |p| {
886                p.strip_prefix(project_root)
887                    .map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
888                    .unwrap_or(p)
889            },
890        );
891
892        let line = loc.range.start.line + 1;
893        let col = loc.range.start.character;
894        out.push_str(&format!("  {path}:{line}:{col}\n"));
895    }
896    out
897}
898
899fn format_workspace_edit(edit: &lsp_types::WorkspaceEdit, project_root: &str) -> String {
900    let mut out = String::from("Rename edits:\n");
901    let mut file_count = 0;
902    let mut edit_count = 0;
903
904    if let Some(ref changes) = edit.changes {
905        for (uri, edits) in changes {
906            let path = uri_to_file_path(uri).map_or_else(
907                || uri.as_str().to_string(),
908                |p| {
909                    p.strip_prefix(project_root)
910                        .map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
911                        .unwrap_or(p)
912                },
913            );
914
915            file_count += 1;
916            out.push_str(&format!("  {path}: {} edit(s)\n", edits.len()));
917            for e in edits {
918                edit_count += 1;
919                let line = e.range.start.line + 1;
920                out.push_str(&format!("    L{line}: -> \"{}\"\n", e.new_text));
921            }
922        }
923    }
924
925    if let Some(ref doc_changes) = edit.document_changes {
926        match doc_changes {
927            lsp_types::DocumentChanges::Edits(edits) => {
928                for text_edit in edits {
929                    let path = uri_to_file_path(&text_edit.text_document.uri)
930                        .unwrap_or_else(|| text_edit.text_document.uri.as_str().to_string());
931                    file_count += 1;
932                    let edits_len = text_edit.edits.len();
933                    edit_count += edits_len;
934                    out.push_str(&format!("  {path}: {edits_len} edit(s)\n"));
935                }
936            }
937            lsp_types::DocumentChanges::Operations(ops) => {
938                for op in ops {
939                    if let lsp_types::DocumentChangeOperation::Edit(text_edit) = op {
940                        let path = uri_to_file_path(&text_edit.text_document.uri)
941                            .unwrap_or_else(|| text_edit.text_document.uri.as_str().to_string());
942                        file_count += 1;
943                        let edits_len = text_edit.edits.len();
944                        edit_count += edits_len;
945                        out.push_str(&format!("  {path}: {edits_len} edit(s)\n"));
946                    }
947                }
948            }
949        }
950    }
951
952    out.push_str(&format!(
953        "\nTotal: {edit_count} edit(s) across {file_count} file(s)."
954    ));
955    out
956}
957
958#[cfg(test)]
959mod tests;
960#[cfg(test)]
961mod tests_ops;