Skip to main content

lean_ctx/lsp/
backend.rs

1//! Backend abstraction for LSP-style code intelligence.
2//!
3//! Two backings implement this trait:
4//!   A) `LspClient` (stdio rust-analyzer) — CI/headless fallback, see client.rs
5//!   B) `JetBrainsHttpBackend` (in-IDE PSI over HTTP) — preferred, see jetbrains_backend.rs
6//!
7//! The 5 mandatory methods exist in both backings (today's behavior must not break).
8//! The default-degrading methods return a clear "unsupported" error unless a backing
9//! (Backing B) overrides them.
10
11use lsp_types::{GotoDefinitionResponse, Location, Position, TextEdit, Uri, WorkspaceEdit};
12
13/// Direction for `type_hierarchy` queries.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum HierarchyDirection {
16    Subtypes,
17    Supertypes,
18}
19
20/// A node in a type hierarchy (super/subtype tree).
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct TypeHierarchyNode {
23    pub name: String,
24    /// Project-relative path of the declaring file.
25    pub path: String,
26    /// 1-indexed line of the declaration.
27    pub line: u32,
28    pub children: Vec<TypeHierarchyNode>,
29}
30
31/// A single symbol entry from a file's structure overview.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct SymbolOverviewItem {
34    pub name: String,
35    pub kind: String,
36    /// 1-indexed line.
37    pub line: u32,
38}
39
40/// A single inspection/diagnostic result.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct InspectionDiag {
43    /// Project-relative path.
44    pub path: String,
45    /// 1-indexed line.
46    pub line: u32,
47    pub severity: String,
48    pub message: String,
49}
50
51/// A single available inspection (the `list` mode of the inspections action).
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct InspectionInfo {
54    /// Stable short name / id of the inspection tool.
55    pub id: String,
56    /// Human-readable display name.
57    pub name: String,
58    /// Severity token: ERROR | WARNING | WEAK_WARNING | INFO.
59    pub severity: String,
60}
61
62/// Truncation metadata for capped result sets (Backing B caps; spec Phase 3/4).
63#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
64pub struct Truncation {
65    pub truncated: bool,
66    /// Total available matches/items (≥ returned count when truncated).
67    pub total: u32,
68}
69
70/// A 0-based, half-open text range (LSP/wire convention: start inclusive, end exclusive).
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct TextRange0Based {
73    pub start_line: u32,
74    pub start_char: u32,
75    pub end_line: u32,
76    pub end_char: u32,
77}
78
79/// A resolved, ready-to-apply edit. The `name_path` → range resolution has already
80/// happened in `ctx_refactor`; the backend only ever sees an absolute path + range.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct RangeEdit {
83    /// Absolute, jail-checked path of the file to edit.
84    pub abs_path: String,
85    /// Project-relative path (for the wire body sent to Backing B).
86    pub rel_path: String,
87    /// The canonical edit boundary (same in IDE and headless paths).
88    pub range: TextRange0Based,
89    /// Final text to write into `range` (indentation already baked in by Rust).
90    pub text: String,
91    /// Optional md5-hex of the current content of `range`; mismatch → CONFLICT.
92    pub expected_hash: Option<String>,
93}
94
95/// Outcome of applying a `RangeEdit`.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct EditResult {
98    pub applied: bool,
99    /// Range covering the newly written text after the edit.
100    pub new_range: TextRange0Based,
101    /// The text that now occupies `new_range`.
102    pub edited_text: String,
103    /// Compact human-readable diff (removed/added lines).
104    pub diff: String,
105}
106
107/// Query for `rename_preview`: the target symbol is already resolved (name_path →
108/// range) in `ctx_refactor`; the backend only ever sees an absolute + relative
109/// path and a range, exactly like `RangeEdit` (no `name_path` on the wire).
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct RenameQuery {
112    /// Absolute, jail-checked path of the file containing the target symbol.
113    pub abs_path: String,
114    /// Project-relative path (wire body sent to Backing B).
115    pub rel_path: String,
116    /// Declaration span of the target symbol (start is what the IDE resolves from).
117    pub target_range: TextRange0Based,
118    pub new_name: String,
119    /// Also rename matches inside comments/strings (RenameProcessor flag).
120    pub search_comments: bool,
121    /// Also rename non-code text occurrences (RenameProcessor flag).
122    pub search_text_occurrences: bool,
123}
124
125/// A single semantic usage of the target symbol (declaration or reference),
126/// returned by Backing B's `RenameProcessor.findUsages`.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct UsageSite {
129    /// Project-relative path of the file holding this usage.
130    pub path: String,
131    /// 0-based range of the renamed identifier at this site.
132    pub range: TextRange0Based,
133    /// Optional one-line context snippet (display only; NOT part of plan_hash).
134    pub context: Option<String>,
135}
136
137/// A refactoring conflict surfaced by `RenameProcessor.preprocessUsages`
138/// (name collision, visibility loss, override clash). `range` is optional —
139/// some conflicts are scope-level, not tied to a single offset.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct Conflict {
142    pub path: String,
143    pub range: Option<TextRange0Based>,
144    pub message: String,
145}
146
147/// Outcome of `rename_preview`: every usage + every conflict. The `plan_hash`
148/// is built in Rust from this (see `ctx_refactor::plan_hash`), never here.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct RenamePlan {
151    pub usages: Vec<UsageSite>,
152    pub conflicts: Vec<Conflict>,
153}
154
155/// Apply request: same target addressing as `RenameQuery` plus the `force`
156/// flag (passed through to `RenameProcessor`; Rust has already gated conflicts).
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct RenameApply {
159    pub abs_path: String,
160    pub rel_path: String,
161    pub target_range: TextRange0Based,
162    pub new_name: String,
163    pub force: bool,
164}
165
166/// Outcome of `rename_apply`: which files the IDE actually changed (no per-file
167/// bodies — Multi-File would be too large; Rust re-reads via mtime validation).
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct RenameResult {
170    pub applied: bool,
171    pub changed_paths: Vec<String>,
172}
173
174/// Where a `move` sends the symbol. Mirrors Serena's two-field dispatch
175/// (`targetRelativePath` XOR `targetParentNamePath`, spec §3): the caller picks
176/// the variant, the backend never sees a `name_path`. Both variants carry the
177/// jail-checked `abs_path` plus the wire-facing `rel_path` (rebuilt by the IDE).
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum MoveTarget {
180    /// Move a file/class into a directory or file (FileMoveProcessor side).
181    Path { abs_path: String, rel_path: String },
182    /// Move a member into a parent symbol (SymbolMoveProcessor side); `range`
183    /// is the parent declaration span used to resolve it in the IDE.
184    Parent {
185        abs_path: String,
186        rel_path: String,
187        range: TextRange0Based,
188    },
189}
190
191/// Phase-1 `move` request: the resolved source span plus an already-resolved,
192/// already-jailed target (the trait never resolves a `name_path` or a path).
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct MoveQuery {
195    pub abs_path: String,
196    pub rel_path: String,
197    pub src_range: TextRange0Based,
198    pub target: MoveTarget,
199}
200
201/// Phase-2 `move` request: the query plus the `force` flag (Rust already gated
202/// plan_hash + conflicts before this is built).
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct MoveApply {
205    pub query: MoveQuery,
206    pub force: bool,
207}
208
209/// Phase-1 `safe_delete` request: just the resolved source span. `*_preview`
210/// returns the remaining (blocking) usages in the reused `RenamePlan`.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct SafeDeleteQuery {
213    pub abs_path: String,
214    pub rel_path: String,
215    pub src_range: TextRange0Based,
216}
217
218/// Phase-2 `safe_delete` request: `force` = Serena's `deleteEvenIfUsed`,
219/// `propagate` = delete now-unreferenced dependencies too.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct SafeDeleteApply {
222    pub query: SafeDeleteQuery,
223    pub force: bool,
224    pub propagate: bool,
225}
226
227/// Phase-1 `inline` request: resolved source span. `keep_definition` maps to the
228/// IntelliJ inline processors' "inline all and keep declaration" flag (spec §3,
229/// Befund 2). The trait never sees a `name_path` — exactly like move/safe_delete.
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct InlineQuery {
232    pub abs_path: String,
233    pub rel_path: String,
234    pub src_range: TextRange0Based,
235    pub keep_definition: bool,
236}
237
238/// Phase-2 `inline` request. NO `force` field — inline conflicts are partly
239/// non-overridable (spec §5.2, Entscheidung 4); the Rust gate is final.
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct InlineApply {
242    pub query: InlineQuery,
243}
244
245/// Reformat scope (spec §5.3): the address is already resolved in `ctx_refactor`;
246/// the trait sees only File / Region{range} / Symbol{range}, never a `name_path`.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum ReformatScope {
249    File,
250    Region { range: TextRange0Based },
251    Symbol { range: TextRange0Based },
252}
253
254/// Single-Phase `reformat` request (spec §5.3): no usages, no plan_hash.
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct ReformatQuery {
257    pub abs_path: String,
258    pub rel_path: String,
259    pub scope: ReformatScope,
260    pub optimize_imports: bool,
261}
262
263/// Outcome of `reformat`: which files changed (Single-File in practice). A
264/// dedicated type makes "reformat has no usage concept" explicit in the type
265/// system (spec §5.4 Empfehlung).
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct ReformatResult {
268    pub applied: bool,
269    pub changed_paths: Vec<String>,
270}
271
272/// Code-intelligence backend. `Send` so instances can live in the global
273/// `BACKENDS` cache (`Mutex<HashMap<String, Box<dyn LspBackend>>>`).
274pub trait LspBackend: Send {
275    // ── Mandatory (both backings) ──
276    fn open_file(&mut self, uri: &Uri, language_id: &str, text: &str) -> Result<(), String>;
277    fn references(
278        &mut self,
279        uri: &Uri,
280        position: Position,
281        scope: &str,
282    ) -> Result<Vec<Location>, String>;
283    fn definition(
284        &mut self,
285        uri: &Uri,
286        position: Position,
287    ) -> Result<GotoDefinitionResponse, String>;
288    fn implementations(
289        &mut self,
290        uri: &Uri,
291        position: Position,
292        scope: &str,
293    ) -> Result<Vec<Location>, String>;
294    fn rename(
295        &mut self,
296        uri: &Uri,
297        position: Position,
298        new_name: &str,
299    ) -> Result<Option<WorkspaceEdit>, String>;
300
301    // ── Default-degrading (Backing B preferred; Backing A keeps the Err) ──
302    fn declaration(&mut self, _uri: &Uri, _position: Position) -> Result<Vec<Location>, String> {
303        Err("declaration requires the JetBrains backend".to_string())
304    }
305    fn type_hierarchy(
306        &mut self,
307        _uri: &Uri,
308        _position: Position,
309        _direction: HierarchyDirection,
310    ) -> Result<TypeHierarchyNode, String> {
311        Err("type_hierarchy requires the JetBrains backend".to_string())
312    }
313    fn symbols_overview(&mut self, uri: &Uri) -> Result<Vec<SymbolOverviewItem>, String> {
314        // v2a §5.2: lossless headless default via the tree-sitter symbol index
315        // (same source as ctx_symbol/ctx_outline). Backing B overrides with PSI.
316        let abs = crate::lsp::client::uri_to_file_path(uri)
317            .ok_or_else(|| "symbols_overview: bad uri".to_string())?;
318        Ok(crate::lsp::edit_apply::overview_from_index(&abs))
319    }
320    fn format(&mut self, _uri: &Uri) -> Result<Vec<TextEdit>, String> {
321        Err("format requires the JetBrains backend".to_string())
322    }
323    fn inspections(&mut self, _uri: &Uri) -> Result<Vec<InspectionDiag>, String> {
324        Err("inspections requires the JetBrains backend".to_string())
325    }
326    fn list_inspections(&mut self) -> Result<Vec<InspectionInfo>, String> {
327        Err("list_inspections requires the JetBrains backend".to_string())
328    }
329
330    /// Replace a symbol's full declaration range with `edit.text`.
331    /// DEFAULT = headless local range write; `JetBrainsHttpBackend` overrides.
332    fn replace_symbol_body(&mut self, edit: &RangeEdit) -> Result<EditResult, String> {
333        crate::lsp::edit_apply::local_range_write(edit)
334    }
335    /// Insert a new sibling before the anchor symbol (range is zero-width at the
336    /// anchor start line; indentation already baked into `edit.text`).
337    fn insert_before_symbol(&mut self, edit: &RangeEdit) -> Result<EditResult, String> {
338        crate::lsp::edit_apply::local_range_write(edit)
339    }
340    /// Insert a new sibling after the anchor symbol (range is zero-width at the
341    /// line following the anchor; indentation already baked into `edit.text`).
342    fn insert_after_symbol(&mut self, edit: &RangeEdit) -> Result<EditResult, String> {
343        crate::lsp::edit_apply::local_range_write(edit)
344    }
345
346    /// Phase 1 of the Two-Phase rename: resolve all usages + conflicts of the
347    /// target symbol. DEFAULT = `Err(BACKEND_REQUIRED)` — there is NO lossless
348    /// headless usage search (spec §3); only Backing B (live IDE) overrides this.
349    fn rename_preview(&mut self, _req: &RenameQuery) -> Result<RenamePlan, String> {
350        Err("BACKEND_REQUIRED: rename requires a running JetBrains IDE".to_string())
351    }
352    /// Phase 2 of the Two-Phase rename: perform the Multi-File rename as ONE
353    /// transaction (one Undo entry). DEFAULT = `Err(BACKEND_REQUIRED)`.
354    fn rename_apply(&mut self, _req: &RenameApply) -> Result<RenameResult, String> {
355        Err("BACKEND_REQUIRED: rename requires a running JetBrains IDE".to_string())
356    }
357
358    /// Phase 1 of the Two-Phase move: resolve all usages + conflicts of the
359    /// target at the new location. DEFAULT = `Err(BACKEND_REQUIRED)` (no lossless
360    /// headless move; only Backing B overrides — spec §5.5).
361    fn move_preview(&mut self, _req: &MoveQuery) -> Result<RenamePlan, String> {
362        Err("BACKEND_REQUIRED: move requires a running JetBrains IDE".to_string())
363    }
364    /// Phase 2 of the Two-Phase move: perform the Multi-File move as ONE Undo
365    /// transaction. DEFAULT = `Err(BACKEND_REQUIRED)`.
366    fn move_apply(&mut self, _req: &MoveApply) -> Result<RenameResult, String> {
367        Err("BACKEND_REQUIRED: move requires a running JetBrains IDE".to_string())
368    }
369    /// Phase 1 of the Two-Phase safe-delete: report the REMAINING (blocking)
370    /// references as `usages`/`conflicts`. DEFAULT = `Err(BACKEND_REQUIRED)`.
371    fn safe_delete_preview(&mut self, _req: &SafeDeleteQuery) -> Result<RenamePlan, String> {
372        Err("BACKEND_REQUIRED: safe_delete requires a running JetBrains IDE".to_string())
373    }
374    /// Phase 2 of the Two-Phase safe-delete: delete the symbol (force =
375    /// deleteEvenIfUsed) as ONE Undo transaction. DEFAULT = `Err(BACKEND_REQUIRED)`.
376    fn safe_delete_apply(&mut self, _req: &SafeDeleteApply) -> Result<RenameResult, String> {
377        Err("BACKEND_REQUIRED: safe_delete requires a running JetBrains IDE".to_string())
378    }
379
380    /// Phase 1 of the Two-Phase inline: resolve all substitution sites + conflicts.
381    /// DEFAULT = `Err(BACKEND_REQUIRED)` — only Backing B (live IDE) overrides (spec §5.4).
382    fn inline_preview(&mut self, _req: &InlineQuery) -> Result<RenamePlan, String> {
383        Err("BACKEND_REQUIRED: inline requires a running JetBrains IDE".to_string())
384    }
385    /// Phase 2 of the Two-Phase inline: substitute at every call site as ONE Undo
386    /// transaction. Hard refusal (recursive, multiple returns, override) → UNSUPPORTED
387    /// at the backend. DEFAULT = `Err(BACKEND_REQUIRED)`.
388    fn inline_apply(&mut self, _req: &InlineApply) -> Result<RenameResult, String> {
389        Err("BACKEND_REQUIRED: inline requires a running JetBrains IDE".to_string())
390    }
391    /// Single-Phase reformat (spec §5.3): no preview, no plan_hash.
392    /// DEFAULT = `Err(BACKEND_REQUIRED)`.
393    fn reformat(&mut self, _req: &ReformatQuery) -> Result<ReformatResult, String> {
394        Err("BACKEND_REQUIRED: reformat requires a running JetBrains IDE".to_string())
395    }
396
397    // ── Self-management (liveness) ──
398    /// Whether a cached instance of this backend is no longer valid and must be
399    /// evicted + re-selected. Backing A (in-process LSP) is never stale → default `false`.
400    /// Backing B overrides: the IDE may have closed/restarted since caching.
401    fn is_stale(&self, _project_root: &str) -> bool {
402        false
403    }
404    /// Truncation metadata of the most recent capped call, or `None` (Backing A,
405    /// or no capped call yet). Lets `ctx_refactor` surface "(truncated …)".
406    fn last_truncation(&self) -> Option<Truncation> {
407        None
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn rename_types_construct_and_clone() {
417        let q = RenameQuery {
418            abs_path: "/proj/a.rs".into(),
419            rel_path: "a.rs".into(),
420            target_range: TextRange0Based {
421                start_line: 0,
422                start_char: 0,
423                end_line: 0,
424                end_char: 3,
425            },
426            new_name: "bar".into(),
427            search_comments: false,
428            search_text_occurrences: false,
429        };
430        let q2 = q.clone();
431        assert_eq!(q2.new_name, "bar");
432
433        let plan = RenamePlan {
434            usages: vec![UsageSite {
435                path: "a.rs".into(),
436                range: TextRange0Based {
437                    start_line: 1,
438                    start_char: 4,
439                    end_line: 1,
440                    end_char: 7,
441                },
442                context: Some("foo()".into()),
443            }],
444            conflicts: vec![Conflict {
445                path: "a.rs".into(),
446                range: None,
447                message: "name already exists".into(),
448            }],
449        };
450        assert_eq!(plan.usages.len(), 1);
451        assert_eq!(plan.conflicts[0].message, "name already exists");
452
453        let apply = RenameApply {
454            abs_path: "/proj/a.rs".into(),
455            rel_path: "a.rs".into(),
456            target_range: q.target_range,
457            new_name: "bar".into(),
458            force: true,
459        };
460        let res = RenameResult {
461            applied: true,
462            changed_paths: vec!["a.rs".into()],
463        };
464        assert!(apply.force);
465        assert!(res.applied);
466    }
467
468    #[test]
469    fn move_and_safe_delete_types_construct_and_clone() {
470        let mt = MoveTarget::Path {
471            abs_path: "/proj/app/moved".into(),
472            rel_path: "app/moved".into(),
473        };
474        let mq = MoveQuery {
475            abs_path: "/proj/Widget.kt".into(),
476            rel_path: "Widget.kt".into(),
477            src_range: TextRange0Based {
478                start_line: 2,
479                start_char: 0,
480                end_line: 2,
481                end_char: 12,
482            },
483            target: mt.clone(),
484        };
485        let ma = MoveApply {
486            query: mq.clone(),
487            force: true,
488        };
489        assert_eq!(ma.query.target, mt);
490
491        let parent = MoveTarget::Parent {
492            abs_path: "/proj/Other.kt".into(),
493            rel_path: "Other.kt".into(),
494            range: TextRange0Based {
495                start_line: 0,
496                start_char: 0,
497                end_line: 5,
498                end_char: 1,
499            },
500        };
501        assert_ne!(parent, mt);
502
503        let sq = SafeDeleteQuery {
504            abs_path: "/proj/Widget.kt".into(),
505            rel_path: "Widget.kt".into(),
506            src_range: TextRange0Based {
507                start_line: 2,
508                start_char: 0,
509                end_line: 2,
510                end_char: 12,
511            },
512        };
513        let sa = SafeDeleteApply {
514            query: sq.clone(),
515            force: true,
516            propagate: false,
517        };
518        assert_eq!(sa.query, sq);
519        assert!(sa.force);
520        assert!(!sa.propagate);
521    }
522
523    #[test]
524    fn inline_and_reformat_types_construct_and_clone() {
525        let range = TextRange0Based {
526            start_line: 1,
527            start_char: 0,
528            end_line: 1,
529            end_char: 9,
530        };
531        let iq = InlineQuery {
532            abs_path: "/p/Calc.kt".into(),
533            rel_path: "Calc.kt".into(),
534            src_range: range,
535            keep_definition: false,
536        };
537        assert_eq!(iq.clone(), iq);
538        let ia = InlineApply { query: iq.clone() };
539        assert!(!ia.clone().query.keep_definition);
540        let rq = ReformatQuery {
541            abs_path: "/p/M.kt".into(),
542            rel_path: "M.kt".into(),
543            scope: ReformatScope::File,
544            optimize_imports: true,
545        };
546        assert_eq!(rq.clone(), rq);
547        assert!(matches!(
548            ReformatQuery {
549                scope: ReformatScope::Region { range },
550                ..rq.clone()
551            }
552            .scope,
553            ReformatScope::Region { .. }
554        ));
555        let rr = ReformatResult {
556            applied: true,
557            changed_paths: vec!["M.kt".into()],
558        };
559        assert_eq!(rr.clone(), rr);
560    }
561
562    #[test]
563    fn headless_inline_and_reformat_default_is_backend_required() {
564        struct Bare2;
565        // minimal LspBackend impl reusing the existing `Bare` pattern (mandatory methods only)
566        impl LspBackend for Bare2 {
567            fn open_file(&mut self, _u: &Uri, _l: &str, _t: &str) -> Result<(), String> {
568                Ok(())
569            }
570            fn references(
571                &mut self,
572                _u: &Uri,
573                _p: Position,
574                _s: &str,
575            ) -> Result<Vec<Location>, String> {
576                Ok(vec![])
577            }
578            fn definition(
579                &mut self,
580                _u: &Uri,
581                _p: Position,
582            ) -> Result<GotoDefinitionResponse, String> {
583                Ok(GotoDefinitionResponse::Array(vec![]))
584            }
585            fn implementations(
586                &mut self,
587                _u: &Uri,
588                _p: Position,
589                _s: &str,
590            ) -> Result<Vec<Location>, String> {
591                Ok(vec![])
592            }
593            fn rename(
594                &mut self,
595                _u: &Uri,
596                _p: Position,
597                _n: &str,
598            ) -> Result<Option<WorkspaceEdit>, String> {
599                Ok(None)
600            }
601        }
602        let q = InlineQuery {
603            abs_path: "/a".into(),
604            rel_path: "a".into(),
605            src_range: TextRange0Based {
606                start_line: 0,
607                start_char: 0,
608                end_line: 0,
609                end_char: 0,
610            },
611            keep_definition: false,
612        };
613        assert!(
614            Bare2
615                .inline_preview(&q)
616                .unwrap_err()
617                .contains("BACKEND_REQUIRED")
618        );
619        assert!(
620            Bare2
621                .inline_apply(&InlineApply { query: q.clone() })
622                .unwrap_err()
623                .contains("BACKEND_REQUIRED")
624        );
625        let rq = ReformatQuery {
626            abs_path: "/a".into(),
627            rel_path: "a".into(),
628            scope: ReformatScope::File,
629            optimize_imports: false,
630        };
631        assert!(
632            Bare2
633                .reformat(&rq)
634                .unwrap_err()
635                .contains("BACKEND_REQUIRED")
636        );
637    }
638
639    #[test]
640    fn headless_rename_default_is_backend_required() {
641        // HeadlessBackend inherits the Trait default → BACKEND_REQUIRED, no apply.
642        let mut be = crate::lsp::edit_apply::HeadlessBackend;
643        let q = RenameQuery {
644            abs_path: "/x".into(),
645            rel_path: "x".into(),
646            target_range: TextRange0Based {
647                start_line: 0,
648                start_char: 0,
649                end_line: 0,
650                end_char: 1,
651            },
652            new_name: "y".into(),
653            search_comments: false,
654            search_text_occurrences: false,
655        };
656        let err = be.rename_preview(&q).unwrap_err();
657        assert!(err.starts_with("BACKEND_REQUIRED"), "got: {err}");
658        let a = RenameApply {
659            abs_path: "/x".into(),
660            rel_path: "x".into(),
661            target_range: q.target_range,
662            new_name: "y".into(),
663            force: false,
664        };
665        assert!(
666            be.rename_apply(&a)
667                .unwrap_err()
668                .starts_with("BACKEND_REQUIRED")
669        );
670    }
671
672    #[test]
673    fn headless_move_and_safe_delete_default_is_backend_required() {
674        // A backend that only implements the mandatory methods inherits the four
675        // v2c Err defaults (no lossless headless move/delete — spec §4 inherited §3).
676        struct Bare;
677        impl LspBackend for Bare {
678            fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
679                Ok(())
680            }
681            fn references(
682                &mut self,
683                _u: &lsp_types::Uri,
684                _p: lsp_types::Position,
685                _s: &str,
686            ) -> Result<Vec<lsp_types::Location>, String> {
687                Ok(vec![])
688            }
689            fn definition(
690                &mut self,
691                _u: &lsp_types::Uri,
692                _p: lsp_types::Position,
693            ) -> Result<lsp_types::GotoDefinitionResponse, String> {
694                Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
695            }
696            fn implementations(
697                &mut self,
698                _u: &lsp_types::Uri,
699                _p: lsp_types::Position,
700                _s: &str,
701            ) -> Result<Vec<lsp_types::Location>, String> {
702                Ok(vec![])
703            }
704            fn rename(
705                &mut self,
706                _u: &lsp_types::Uri,
707                _p: lsp_types::Position,
708                _n: &str,
709            ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
710                Ok(None)
711            }
712        }
713        let mut b = Bare;
714        let mq = MoveQuery {
715            abs_path: "/p/a.kt".into(),
716            rel_path: "a.kt".into(),
717            src_range: TextRange0Based {
718                start_line: 0,
719                start_char: 0,
720                end_line: 0,
721                end_char: 1,
722            },
723            target: MoveTarget::Path {
724                abs_path: "/p/x".into(),
725                rel_path: "x".into(),
726            },
727        };
728        assert!(
729            b.move_preview(&mq)
730                .unwrap_err()
731                .starts_with("BACKEND_REQUIRED")
732        );
733        assert!(
734            b.move_apply(&MoveApply {
735                query: mq,
736                force: false
737            })
738            .unwrap_err()
739            .starts_with("BACKEND_REQUIRED")
740        );
741        let sq = SafeDeleteQuery {
742            abs_path: "/p/a.kt".into(),
743            rel_path: "a.kt".into(),
744            src_range: TextRange0Based {
745                start_line: 0,
746                start_char: 0,
747                end_line: 0,
748                end_char: 1,
749            },
750        };
751        assert!(
752            b.safe_delete_preview(&sq)
753                .unwrap_err()
754                .starts_with("BACKEND_REQUIRED")
755        );
756        assert!(
757            b.safe_delete_apply(&SafeDeleteApply {
758                query: sq,
759                force: false,
760                propagate: false
761            })
762            .unwrap_err()
763            .starts_with("BACKEND_REQUIRED")
764        );
765    }
766}