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. Checked before every call, so it must stay cheap.
400    /// Backing A overrides: the server process exited. Backing B overrides: the
401    /// IDE may have closed/restarted since caching.
402    fn is_stale(&self, _project_root: &str) -> bool {
403        false
404    }
405    /// After a failed call: is the backend dead (evict) rather than reporting an
406    /// ordinary error (keep)? Runs only on the error path, so it may afford a
407    /// costlier probe than [`Self::is_stale`]. Defaults to `is_stale`.
408    fn is_dead_after_error(&self, project_root: &str) -> bool {
409        self.is_stale(project_root)
410    }
411    /// Caps how long a single request may block (`None` restores the default).
412    /// Opportunistic semantic work sets a short deadline so it never holds the
413    /// backend long; backends without a per-request timeout ignore it.
414    fn set_request_timeout(&mut self, _timeout: Option<std::time::Duration>) {}
415    /// Identity, negotiated capabilities and position encoding. The default
416    /// describes a backend offering exactly the five mandatory operations.
417    fn backend_info(&self) -> crate::lsp::capabilities::SemanticBackendInfo {
418        use crate::lsp::capabilities::{
419            SemanticBackendInfo, SemanticBackendKind, SemanticCapabilities,
420        };
421        SemanticBackendInfo {
422            kind: SemanticBackendKind::Lsp,
423            server_name: None,
424            server_version: None,
425            capabilities: SemanticCapabilities {
426                definition: true,
427                references: true,
428                implementations: true,
429                rename: true,
430                ..SemanticCapabilities::default()
431            },
432            utf8_positions: false,
433        }
434    }
435    /// Truncation metadata of the most recent capped call, or `None` (Backing A,
436    /// or no capped call yet). Lets `ctx_refactor` surface "(truncated …)".
437    fn last_truncation(&self) -> Option<Truncation> {
438        None
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn rename_types_construct_and_clone() {
448        let q = RenameQuery {
449            abs_path: "/proj/a.rs".into(),
450            rel_path: "a.rs".into(),
451            target_range: TextRange0Based {
452                start_line: 0,
453                start_char: 0,
454                end_line: 0,
455                end_char: 3,
456            },
457            new_name: "bar".into(),
458            search_comments: false,
459            search_text_occurrences: false,
460        };
461        let q2 = q.clone();
462        assert_eq!(q2.new_name, "bar");
463
464        let plan = RenamePlan {
465            usages: vec![UsageSite {
466                path: "a.rs".into(),
467                range: TextRange0Based {
468                    start_line: 1,
469                    start_char: 4,
470                    end_line: 1,
471                    end_char: 7,
472                },
473                context: Some("foo()".into()),
474            }],
475            conflicts: vec![Conflict {
476                path: "a.rs".into(),
477                range: None,
478                message: "name already exists".into(),
479            }],
480        };
481        assert_eq!(plan.usages.len(), 1);
482        assert_eq!(plan.conflicts[0].message, "name already exists");
483
484        let apply = RenameApply {
485            abs_path: "/proj/a.rs".into(),
486            rel_path: "a.rs".into(),
487            target_range: q.target_range,
488            new_name: "bar".into(),
489            force: true,
490        };
491        let res = RenameResult {
492            applied: true,
493            changed_paths: vec!["a.rs".into()],
494        };
495        assert!(apply.force);
496        assert!(res.applied);
497    }
498
499    #[test]
500    fn move_and_safe_delete_types_construct_and_clone() {
501        let mt = MoveTarget::Path {
502            abs_path: "/proj/app/moved".into(),
503            rel_path: "app/moved".into(),
504        };
505        let mq = MoveQuery {
506            abs_path: "/proj/Widget.kt".into(),
507            rel_path: "Widget.kt".into(),
508            src_range: TextRange0Based {
509                start_line: 2,
510                start_char: 0,
511                end_line: 2,
512                end_char: 12,
513            },
514            target: mt.clone(),
515        };
516        let ma = MoveApply {
517            query: mq.clone(),
518            force: true,
519        };
520        assert_eq!(ma.query.target, mt);
521
522        let parent = MoveTarget::Parent {
523            abs_path: "/proj/Other.kt".into(),
524            rel_path: "Other.kt".into(),
525            range: TextRange0Based {
526                start_line: 0,
527                start_char: 0,
528                end_line: 5,
529                end_char: 1,
530            },
531        };
532        assert_ne!(parent, mt);
533
534        let sq = SafeDeleteQuery {
535            abs_path: "/proj/Widget.kt".into(),
536            rel_path: "Widget.kt".into(),
537            src_range: TextRange0Based {
538                start_line: 2,
539                start_char: 0,
540                end_line: 2,
541                end_char: 12,
542            },
543        };
544        let sa = SafeDeleteApply {
545            query: sq.clone(),
546            force: true,
547            propagate: false,
548        };
549        assert_eq!(sa.query, sq);
550        assert!(sa.force);
551        assert!(!sa.propagate);
552    }
553
554    #[test]
555    fn inline_and_reformat_types_construct_and_clone() {
556        let range = TextRange0Based {
557            start_line: 1,
558            start_char: 0,
559            end_line: 1,
560            end_char: 9,
561        };
562        let iq = InlineQuery {
563            abs_path: "/p/Calc.kt".into(),
564            rel_path: "Calc.kt".into(),
565            src_range: range,
566            keep_definition: false,
567        };
568        assert_eq!(iq.clone(), iq);
569        let ia = InlineApply { query: iq.clone() };
570        assert!(!ia.clone().query.keep_definition);
571        let rq = ReformatQuery {
572            abs_path: "/p/M.kt".into(),
573            rel_path: "M.kt".into(),
574            scope: ReformatScope::File,
575            optimize_imports: true,
576        };
577        assert_eq!(rq.clone(), rq);
578        assert!(matches!(
579            ReformatQuery {
580                scope: ReformatScope::Region { range },
581                ..rq.clone()
582            }
583            .scope,
584            ReformatScope::Region { .. }
585        ));
586        let rr = ReformatResult {
587            applied: true,
588            changed_paths: vec!["M.kt".into()],
589        };
590        assert_eq!(rr.clone(), rr);
591    }
592
593    #[test]
594    fn headless_inline_and_reformat_default_is_backend_required() {
595        struct Bare2;
596        // minimal LspBackend impl reusing the existing `Bare` pattern (mandatory methods only)
597        impl LspBackend for Bare2 {
598            fn open_file(&mut self, _u: &Uri, _l: &str, _t: &str) -> Result<(), String> {
599                Ok(())
600            }
601            fn references(
602                &mut self,
603                _u: &Uri,
604                _p: Position,
605                _s: &str,
606            ) -> Result<Vec<Location>, String> {
607                Ok(vec![])
608            }
609            fn definition(
610                &mut self,
611                _u: &Uri,
612                _p: Position,
613            ) -> Result<GotoDefinitionResponse, String> {
614                Ok(GotoDefinitionResponse::Array(vec![]))
615            }
616            fn implementations(
617                &mut self,
618                _u: &Uri,
619                _p: Position,
620                _s: &str,
621            ) -> Result<Vec<Location>, String> {
622                Ok(vec![])
623            }
624            fn rename(
625                &mut self,
626                _u: &Uri,
627                _p: Position,
628                _n: &str,
629            ) -> Result<Option<WorkspaceEdit>, String> {
630                Ok(None)
631            }
632        }
633        let q = InlineQuery {
634            abs_path: "/a".into(),
635            rel_path: "a".into(),
636            src_range: TextRange0Based {
637                start_line: 0,
638                start_char: 0,
639                end_line: 0,
640                end_char: 0,
641            },
642            keep_definition: false,
643        };
644        assert!(
645            Bare2
646                .inline_preview(&q)
647                .unwrap_err()
648                .contains("BACKEND_REQUIRED")
649        );
650        assert!(
651            Bare2
652                .inline_apply(&InlineApply { query: q.clone() })
653                .unwrap_err()
654                .contains("BACKEND_REQUIRED")
655        );
656        let rq = ReformatQuery {
657            abs_path: "/a".into(),
658            rel_path: "a".into(),
659            scope: ReformatScope::File,
660            optimize_imports: false,
661        };
662        assert!(
663            Bare2
664                .reformat(&rq)
665                .unwrap_err()
666                .contains("BACKEND_REQUIRED")
667        );
668    }
669
670    #[test]
671    fn headless_rename_default_is_backend_required() {
672        // HeadlessBackend inherits the Trait default → BACKEND_REQUIRED, no apply.
673        let mut be = crate::lsp::edit_apply::HeadlessBackend;
674        let q = RenameQuery {
675            abs_path: "/x".into(),
676            rel_path: "x".into(),
677            target_range: TextRange0Based {
678                start_line: 0,
679                start_char: 0,
680                end_line: 0,
681                end_char: 1,
682            },
683            new_name: "y".into(),
684            search_comments: false,
685            search_text_occurrences: false,
686        };
687        let err = be.rename_preview(&q).unwrap_err();
688        assert!(err.starts_with("BACKEND_REQUIRED"), "got: {err}");
689        let a = RenameApply {
690            abs_path: "/x".into(),
691            rel_path: "x".into(),
692            target_range: q.target_range,
693            new_name: "y".into(),
694            force: false,
695        };
696        assert!(
697            be.rename_apply(&a)
698                .unwrap_err()
699                .starts_with("BACKEND_REQUIRED")
700        );
701    }
702
703    #[test]
704    fn headless_move_and_safe_delete_default_is_backend_required() {
705        // A backend that only implements the mandatory methods inherits the four
706        // v2c Err defaults (no lossless headless move/delete — spec §4 inherited §3).
707        struct Bare;
708        impl LspBackend for Bare {
709            fn open_file(&mut self, _u: &lsp_types::Uri, _l: &str, _t: &str) -> Result<(), String> {
710                Ok(())
711            }
712            fn references(
713                &mut self,
714                _u: &lsp_types::Uri,
715                _p: lsp_types::Position,
716                _s: &str,
717            ) -> Result<Vec<lsp_types::Location>, String> {
718                Ok(vec![])
719            }
720            fn definition(
721                &mut self,
722                _u: &lsp_types::Uri,
723                _p: lsp_types::Position,
724            ) -> Result<lsp_types::GotoDefinitionResponse, String> {
725                Ok(lsp_types::GotoDefinitionResponse::Array(vec![]))
726            }
727            fn implementations(
728                &mut self,
729                _u: &lsp_types::Uri,
730                _p: lsp_types::Position,
731                _s: &str,
732            ) -> Result<Vec<lsp_types::Location>, String> {
733                Ok(vec![])
734            }
735            fn rename(
736                &mut self,
737                _u: &lsp_types::Uri,
738                _p: lsp_types::Position,
739                _n: &str,
740            ) -> Result<Option<lsp_types::WorkspaceEdit>, String> {
741                Ok(None)
742            }
743        }
744        let mut b = Bare;
745        let mq = MoveQuery {
746            abs_path: "/p/a.kt".into(),
747            rel_path: "a.kt".into(),
748            src_range: TextRange0Based {
749                start_line: 0,
750                start_char: 0,
751                end_line: 0,
752                end_char: 1,
753            },
754            target: MoveTarget::Path {
755                abs_path: "/p/x".into(),
756                rel_path: "x".into(),
757            },
758        };
759        assert!(
760            b.move_preview(&mq)
761                .unwrap_err()
762                .starts_with("BACKEND_REQUIRED")
763        );
764        assert!(
765            b.move_apply(&MoveApply {
766                query: mq,
767                force: false
768            })
769            .unwrap_err()
770            .starts_with("BACKEND_REQUIRED")
771        );
772        let sq = SafeDeleteQuery {
773            abs_path: "/p/a.kt".into(),
774            rel_path: "a.kt".into(),
775            src_range: TextRange0Based {
776                start_line: 0,
777                start_char: 0,
778                end_line: 0,
779                end_char: 1,
780            },
781        };
782        assert!(
783            b.safe_delete_preview(&sq)
784                .unwrap_err()
785                .starts_with("BACKEND_REQUIRED")
786        );
787        assert!(
788            b.safe_delete_apply(&SafeDeleteApply {
789                query: sq,
790                force: false,
791                propagate: false
792            })
793            .unwrap_err()
794            .starts_with("BACKEND_REQUIRED")
795        );
796    }
797}