Skip to main content

lean_ctx/lsp/
jetbrains_backend.rs

1//! Backing B: in-IDE JetBrains PSI backend over HTTP/JSON (127.0.0.1).
2//! Synchronous (`ureq`) — matches the synchronous `McpTool::handle` path and does
3//! not block the Tokio runtime. Phase 1 implements references/definition/
4//! implementations; rename + the degrading ops follow in later phases.
5
6use std::time::Duration;
7
8use lsp_types::{GotoDefinitionResponse, Location, Position, Range, Uri, WorkspaceEdit};
9use serde_json::Value;
10
11use crate::lsp::backend::{
12    EditResult, HierarchyDirection, InspectionDiag, InspectionInfo, LspBackend, RangeEdit,
13    SymbolOverviewItem, TextRange0Based, TypeHierarchyNode,
14};
15use crate::lsp::client::file_path_to_uri;
16
17const REQUEST_TIMEOUT_SECS: u64 = 30;
18
19pub struct JetBrainsHttpBackend {
20    base_url: String,
21    token: String,
22    /// Absolute project root, to rejoin project-relative wire paths.
23    project_root: String,
24    /// IDE process id from the discovered port file — for cheap staleness checks.
25    pid: u32,
26    /// IDE listen port — re-compared against the port file to detect restarts.
27    port: u16,
28    /// Truncation meta of the most recent capped call (references/implementations/
29    /// type_hierarchy/symbols_overview), surfaced by ctx_refactor.
30    last_meta: Option<crate::lsp::backend::Truncation>,
31}
32
33impl JetBrainsHttpBackend {
34    /// Canonicalize the project root ONCE so project-relative wire paths rejoin
35    /// byte-identically with the Kotlin side (port-file key = sha256(realpath)[..16]).
36    /// Mirrors `port_discovery::project_hash` canonicalization. On error (e.g. path
37    /// does not exist), fall back to the raw root with a trailing-slash trim.
38    fn canonical_root(project_root: &str) -> String {
39        let canonical = std::fs::canonicalize(project_root).map_or_else(
40            |_| project_root.to_string(),
41            |p| p.to_string_lossy().to_string(),
42        );
43        canonical
44            .strip_suffix('/')
45            .unwrap_or(&canonical)
46            .to_string()
47    }
48
49    #[allow(clippy::needless_pass_by_value)] // public ctor; callers own String
50    pub fn new(port: u16, token: String, project_root: String, pid: u32) -> Self {
51        Self {
52            base_url: format!("http://127.0.0.1:{port}"),
53            token,
54            project_root: Self::canonical_root(&project_root),
55            pid,
56            port,
57            last_meta: None,
58        }
59    }
60
61    #[cfg(test)]
62    fn project_root_for_test(&self) -> &str {
63        &self.project_root
64    }
65
66    fn post(&self, endpoint: &str, body: &Value) -> Result<Value, String> {
67        let url = format!("{}{endpoint}", self.base_url);
68        // ureq 3.x + repo convention (NO `json` feature): serialize via serde_json,
69        // send raw bytes, read response body as string, parse. Per-request timeout via
70        // `.config().timeout_global(..).build()`. Pattern mirrors port_discovery.rs + llm_enhance.rs.
71        let payload = serde_json::to_vec(body).map_err(|e| format!("serialize request: {e}"))?;
72        let resp = ureq::post(&url)
73            .config()
74            .timeout_global(Some(Duration::from_secs(REQUEST_TIMEOUT_SECS)))
75            .build()
76            .header("X-LeanCtx-Token", &self.token)
77            .header("Content-Type", "application/json")
78            .send(payload.as_slice())
79            .map_err(|e| format!("JetBrains backend request to {endpoint} failed: {e}"))?;
80        let text = resp
81            .into_body()
82            .read_to_string()
83            .map_err(|e| format!("JetBrains backend: read response: {e}"))?;
84        serde_json::from_str(&text).map_err(|e| format!("JetBrains backend: parse response: {e}"))
85    }
86
87    /// Project-relative path → absolute file URI (Rust rejoins, spec §6).
88    fn rel_to_uri(&self, rel: &str) -> Option<Uri> {
89        let abs = format!("{}/{}", self.project_root, rel);
90        file_path_to_uri(&abs).ok()
91    }
92
93    fn parse_position(v: &Value) -> Option<Position> {
94        let line = v.get("line")?.as_u64()? as u32;
95        let character = v.get("character")?.as_u64()? as u32;
96        Some(Position { line, character })
97    }
98
99    fn parse_locations(&self, v: &Value) -> Vec<Location> {
100        v.get("locations")
101            .and_then(Value::as_array)
102            .map(|arr| {
103                arr.iter()
104                    .filter_map(|loc| {
105                        let rel = loc.get("path")?.as_str()?;
106                        let uri = self.rel_to_uri(rel)?;
107                        let range = loc.get("range")?;
108                        let start = Self::parse_position(range.get("start")?)?;
109                        let end = Self::parse_position(range.get("end")?)?;
110                        Some(Location {
111                            uri,
112                            range: Range { start, end },
113                        })
114                    })
115                    .collect()
116            })
117            .unwrap_or_default()
118    }
119
120    fn parse_type_hierarchy(v: &Value) -> TypeHierarchyNode {
121        fn node(v: &Value) -> TypeHierarchyNode {
122            TypeHierarchyNode {
123                name: v
124                    .get("name")
125                    .and_then(Value::as_str)
126                    .unwrap_or("?")
127                    .to_string(),
128                path: v
129                    .get("path")
130                    .and_then(Value::as_str)
131                    .unwrap_or_default()
132                    .to_string(),
133                line: v.get("line").and_then(Value::as_u64).unwrap_or(0) as u32,
134                children: v
135                    .get("children")
136                    .and_then(Value::as_array)
137                    .map(|arr| arr.iter().map(node).collect())
138                    .unwrap_or_default(),
139            }
140        }
141        v.get("tree").map_or_else(
142            || TypeHierarchyNode {
143                name: String::new(),
144                path: String::new(),
145                line: 0,
146                children: vec![],
147            },
148            node,
149        )
150    }
151
152    fn parse_symbols(v: &Value) -> Vec<SymbolOverviewItem> {
153        v.get("symbols")
154            .and_then(Value::as_array)
155            .map(|arr| {
156                arr.iter()
157                    .filter_map(|s| {
158                        Some(SymbolOverviewItem {
159                            name: s.get("name")?.as_str()?.to_string(),
160                            kind: s.get("kind")?.as_str()?.to_string(),
161                            line: s.get("line")?.as_u64()? as u32,
162                        })
163                    })
164                    .collect()
165            })
166            .unwrap_or_default()
167    }
168
169    fn parse_inspections(v: &Value) -> Vec<InspectionDiag> {
170        v.get("diagnostics")
171            .and_then(Value::as_array)
172            .map(|arr| {
173                arr.iter()
174                    .filter_map(|d| {
175                        Some(InspectionDiag {
176                            path: d.get("path")?.as_str()?.to_string(),
177                            line: d.get("line")?.as_u64()? as u32,
178                            severity: d.get("severity")?.as_str()?.to_string(),
179                            message: d.get("message")?.as_str()?.to_string(),
180                        })
181                    })
182                    .collect()
183            })
184            .unwrap_or_default()
185    }
186
187    fn parse_inspection_list(v: &Value) -> Vec<InspectionInfo> {
188        v.get("inspections")
189            .and_then(Value::as_array)
190            .map(|arr| {
191                arr.iter()
192                    .filter_map(|i| {
193                        Some(InspectionInfo {
194                            id: i.get("id")?.as_str()?.to_string(),
195                            name: i.get("name")?.as_str()?.to_string(),
196                            severity: i.get("severity")?.as_str()?.to_string(),
197                        })
198                    })
199                    .collect()
200            })
201            .unwrap_or_default()
202    }
203
204    fn parse_truncation(v: &Value, shown: u32) -> Option<crate::lsp::backend::Truncation> {
205        let truncated = v.get("truncated").and_then(Value::as_bool)?;
206        let total = v
207            .get("total")
208            .and_then(Value::as_u64)
209            .map_or(shown, |n| n as u32);
210        Some(crate::lsp::backend::Truncation { truncated, total })
211    }
212
213    fn parse_edit_result(v: &Value, fallback_text: &str) -> EditResult {
214        let pos = |obj: &Value, key: &str| -> (u32, u32) {
215            let p = obj.get(key);
216            let line = p
217                .and_then(|p| p.get("line"))
218                .and_then(Value::as_u64)
219                .unwrap_or(0) as u32;
220            let ch = p
221                .and_then(|p| p.get("character"))
222                .and_then(Value::as_u64)
223                .unwrap_or(0) as u32;
224            (line, ch)
225        };
226        let nr = v.get("newRange");
227        let (sl, sc) = nr.map_or((0, 0), |r| pos(r, "start"));
228        let (el, ec) = nr.map_or((0, 0), |r| pos(r, "end"));
229        EditResult {
230            applied: v.get("applied").and_then(Value::as_bool).unwrap_or(false),
231            new_range: TextRange0Based {
232                start_line: sl,
233                start_char: sc,
234                end_line: el,
235                end_char: ec,
236            },
237            edited_text: v
238                .get("editedText")
239                .and_then(Value::as_str)
240                .unwrap_or(fallback_text)
241                .to_string(),
242            diff: String::new(), // Rust builds the diff in ctx_refactor from old/new
243        }
244    }
245
246    /// `{path}` request body (file-level ops, no position).
247    fn path_body(&self, uri: &Uri) -> Value {
248        let abs = crate::lsp::client::uri_to_file_path(uri).unwrap_or_default();
249        let rel = abs
250            .strip_prefix(&self.project_root)
251            .map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
252            .unwrap_or(abs);
253        serde_json::json!({ "path": rel })
254    }
255
256    /// Build the `{path, line, character}` request body. `position` is already
257    /// 0-based (LSP convention) — sent verbatim. `uri` → project-relative path.
258    fn position_body(&self, uri: &Uri, position: Position) -> Value {
259        let abs = crate::lsp::client::uri_to_file_path(uri).unwrap_or_default();
260        let rel = abs
261            .strip_prefix(&self.project_root)
262            .map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
263            .unwrap_or(abs);
264        serde_json::json!({
265            "path": rel,
266            "line": position.line,
267            "character": position.character,
268        })
269    }
270
271    /// POST a resolved edit to the plugin and parse the result. The wire range is
272    /// the canonical tree-sitter range (byte-identical to the headless path).
273    fn post_edit(&self, endpoint: &str, edit: &RangeEdit) -> Result<EditResult, String> {
274        let mut body = serde_json::json!({
275            "path": edit.rel_path,
276            "range": {
277                "start": { "line": edit.range.start_line, "character": edit.range.start_char },
278                "end":   { "line": edit.range.end_line,   "character": edit.range.end_char },
279            },
280            "text": edit.text,
281        });
282        if let Some(h) = &edit.expected_hash {
283            body["expected_hash"] = serde_json::json!(h);
284        }
285        let resp = self.post(endpoint, &body)?;
286        if let Some(err) = resp.get("error") {
287            return Err(Self::error_from_envelope(err));
288        }
289        Ok(Self::parse_edit_result(&resp, &edit.text))
290    }
291
292    /// Parse a `{start,end}` range object into `TextRange0Based`.
293    fn parse_range0(v: &Value) -> Option<crate::lsp::backend::TextRange0Based> {
294        let start = Self::parse_position(v.get("start")?)?;
295        let end = Self::parse_position(v.get("end")?)?;
296        Some(crate::lsp::backend::TextRange0Based {
297            start_line: start.line,
298            start_char: start.character,
299            end_line: end.line,
300            end_char: end.character,
301        })
302    }
303
304    fn parse_rename_plan(v: &Value) -> crate::lsp::backend::RenamePlan {
305        use crate::lsp::backend::{Conflict, RenamePlan, UsageSite};
306        let usages = v
307            .get("usages")
308            .and_then(Value::as_array)
309            .map(|arr| {
310                arr.iter()
311                    .filter_map(|u| {
312                        Some(UsageSite {
313                            path: u.get("path")?.as_str()?.to_string(),
314                            range: Self::parse_range0(u.get("range")?)?,
315                            context: u.get("context").and_then(Value::as_str).map(String::from),
316                        })
317                    })
318                    .collect()
319            })
320            .unwrap_or_default();
321        let conflicts = v
322            .get("conflicts")
323            .and_then(Value::as_array)
324            .map(|arr| {
325                arr.iter()
326                    .filter_map(|c| {
327                        Some(Conflict {
328                            path: c.get("path")?.as_str()?.to_string(),
329                            range: c.get("range").and_then(Self::parse_range0),
330                            message: c.get("message")?.as_str()?.to_string(),
331                        })
332                    })
333                    .collect()
334            })
335            .unwrap_or_default();
336        RenamePlan { usages, conflicts }
337    }
338
339    /// Common `{path, range, new_name}` request body for both rename endpoints.
340    fn rename_body(
341        rel_path: &str,
342        range: crate::lsp::backend::TextRange0Based,
343        new_name: &str,
344    ) -> Value {
345        serde_json::json!({
346            "path": rel_path,
347            "range": {
348                "start": { "line": range.start_line, "character": range.start_char },
349                "end":   { "line": range.end_line,   "character": range.end_char },
350            },
351            "new_name": new_name,
352        })
353    }
354
355    /// Request body for `/movePreview` + `/moveApply`. `target` mirrors the
356    /// MoveTarget variant (kind=path → `{path}`, kind=parent → `{path,range}`).
357    fn move_body(
358        rel_path: &str,
359        src_range: crate::lsp::backend::TextRange0Based,
360        target: &crate::lsp::backend::MoveTarget,
361    ) -> Value {
362        use crate::lsp::backend::MoveTarget;
363        let target_json = match target {
364            MoveTarget::Path { rel_path: tp, .. } => serde_json::json!({
365                "kind": "path",
366                "path": tp,
367            }),
368            MoveTarget::Parent {
369                rel_path: pp,
370                range,
371                ..
372            } => serde_json::json!({
373                "kind": "parent",
374                "path": pp,
375                "range": {
376                    "start": { "line": range.start_line, "character": range.start_char },
377                    "end":   { "line": range.end_line,   "character": range.end_char },
378                },
379            }),
380        };
381        serde_json::json!({
382            "path": rel_path,
383            "range": {
384                "start": { "line": src_range.start_line, "character": src_range.start_char },
385                "end":   { "line": src_range.end_line,   "character": src_range.end_char },
386            },
387            "target": target_json,
388        })
389    }
390
391    /// Request body for `/safeDeletePreview` (force/propagate ignored there) +
392    /// `/safeDeleteApply`.
393    fn safe_delete_body(
394        rel_path: &str,
395        src_range: crate::lsp::backend::TextRange0Based,
396        force: bool,
397        propagate: bool,
398    ) -> Value {
399        serde_json::json!({
400            "path": rel_path,
401            "range": {
402                "start": { "line": src_range.start_line, "character": src_range.start_char },
403                "end":   { "line": src_range.end_line,   "character": src_range.end_char },
404            },
405            "force": force,
406            "propagate": propagate,
407        })
408    }
409
410    /// Parse a `{applied, changed_paths}` apply response (shared by rename/move/
411    /// safe_delete apply). Error envelopes are handled by the caller.
412    fn parse_apply_result(resp: &Value) -> crate::lsp::backend::RenameResult {
413        let changed_paths = resp
414            .get("changed_paths")
415            .and_then(Value::as_array)
416            .map(|a| {
417                a.iter()
418                    .filter_map(|p| p.as_str().map(String::from))
419                    .collect()
420            })
421            .unwrap_or_default();
422        crate::lsp::backend::RenameResult {
423            applied: resp
424                .get("applied")
425                .and_then(Value::as_bool)
426                .unwrap_or(false),
427            changed_paths,
428        }
429    }
430
431    /// Build an error message from a backend error envelope: the structured `code` plus
432    /// `": message"` when a non-empty detail message is present (else just the code). Keeps
433    /// the code prefix that callers/tests match on while preserving the human-readable detail.
434    fn error_from_envelope(err: &Value) -> String {
435        let code = err
436            .get("code")
437            .and_then(Value::as_str)
438            .unwrap_or("INTERNAL");
439        match err.get("message").and_then(Value::as_str) {
440            Some(m) if !m.is_empty() => format!("{code}: {m}"),
441            _ => code.to_string(),
442        }
443    }
444
445    /// Request body for `/inlinePreview` + `/inlineApply` (no force — spec §5.2).
446    fn inline_body(
447        rel_path: &str,
448        src_range: crate::lsp::backend::TextRange0Based,
449        keep_definition: bool,
450    ) -> Value {
451        serde_json::json!({
452            "path": rel_path,
453            "range": {
454                "start": { "line": src_range.start_line, "character": src_range.start_char },
455                "end":   { "line": src_range.end_line,   "character": src_range.end_char },
456            },
457            "keep_definition": keep_definition,
458        })
459    }
460
461    /// Request body for `/reformat`. scope.kind ∈ {file, region, symbol};
462    /// region/symbol carry a 0-based range, file omits it.
463    fn reformat_body(
464        rel_path: &str,
465        scope: &crate::lsp::backend::ReformatScope,
466        optimize_imports: bool,
467    ) -> Value {
468        use crate::lsp::backend::ReformatScope;
469        let scope_json = match scope {
470            ReformatScope::File => serde_json::json!({ "kind": "file" }),
471            ReformatScope::Region { range } => serde_json::json!({
472                "kind": "region",
473                "range": {
474                    "start": { "line": range.start_line, "character": range.start_char },
475                    "end":   { "line": range.end_line,   "character": range.end_char },
476                },
477            }),
478            ReformatScope::Symbol { range } => serde_json::json!({
479                "kind": "symbol",
480                "range": {
481                    "start": { "line": range.start_line, "character": range.start_char },
482                    "end":   { "line": range.end_line,   "character": range.end_char },
483                },
484            }),
485        };
486        serde_json::json!({
487            "path": rel_path,
488            "scope": scope_json,
489            "optimize_imports": optimize_imports,
490        })
491    }
492
493    /// Parse a `{applied, changed_paths}` reformat response.
494    fn parse_reformat_result(resp: &Value) -> crate::lsp::backend::ReformatResult {
495        let changed_paths = resp
496            .get("changed_paths")
497            .and_then(Value::as_array)
498            .map(|a| {
499                a.iter()
500                    .filter_map(|p| p.as_str().map(String::from))
501                    .collect()
502            })
503            .unwrap_or_default();
504        crate::lsp::backend::ReformatResult {
505            applied: resp
506                .get("applied")
507                .and_then(Value::as_bool)
508                .unwrap_or(false),
509            changed_paths,
510        }
511    }
512}
513
514impl LspBackend for JetBrainsHttpBackend {
515    fn open_file(&mut self, _uri: &Uri, _language_id: &str, _text: &str) -> Result<(), String> {
516        // The IDE already has the file in its VFS/index — no explicit open needed.
517        Ok(())
518    }
519
520    fn references(
521        &mut self,
522        uri: &Uri,
523        position: Position,
524        scope: &str,
525    ) -> Result<Vec<Location>, String> {
526        let mut body = self.position_body(uri, position);
527        body["scope"] = serde_json::json!(scope);
528        let resp = self.post("/references", &body)?;
529        let locs = self.parse_locations(&resp);
530        self.last_meta = Self::parse_truncation(&resp, locs.len() as u32);
531        Ok(locs)
532    }
533
534    fn definition(
535        &mut self,
536        uri: &Uri,
537        position: Position,
538    ) -> Result<GotoDefinitionResponse, String> {
539        let body = self.position_body(uri, position);
540        let resp = self.post("/definition", &body)?;
541        Ok(GotoDefinitionResponse::Array(self.parse_locations(&resp)))
542    }
543
544    fn implementations(
545        &mut self,
546        uri: &Uri,
547        position: Position,
548        scope: &str,
549    ) -> Result<Vec<Location>, String> {
550        let mut body = self.position_body(uri, position);
551        body["scope"] = serde_json::json!(scope);
552        let resp = self.post("/implementations", &body)?;
553        let locs = self.parse_locations(&resp);
554        self.last_meta = Self::parse_truncation(&resp, locs.len() as u32);
555        Ok(locs)
556    }
557
558    fn declaration(&mut self, uri: &Uri, position: Position) -> Result<Vec<Location>, String> {
559        let body = self.position_body(uri, position);
560        let resp = self.post("/declaration", &body)?;
561        Ok(self.parse_locations(&resp))
562    }
563
564    fn type_hierarchy(
565        &mut self,
566        uri: &Uri,
567        position: Position,
568        direction: HierarchyDirection,
569    ) -> Result<TypeHierarchyNode, String> {
570        let mut body = self.position_body(uri, position);
571        body["direction"] = serde_json::json!(match direction {
572            HierarchyDirection::Supertypes => "supertypes",
573            HierarchyDirection::Subtypes => "subtypes",
574        });
575        let resp = self.post("/type_hierarchy", &body)?;
576        if let Some(err) = resp.get("error") {
577            return Err(Self::error_from_envelope(err));
578        }
579        self.last_meta = Self::parse_truncation(&resp, 0);
580        Ok(Self::parse_type_hierarchy(&resp))
581    }
582
583    fn symbols_overview(&mut self, uri: &Uri) -> Result<Vec<SymbolOverviewItem>, String> {
584        let body = self.path_body(uri);
585        let resp = self.post("/symbols_overview", &body)?;
586        if let Some(err) = resp.get("error") {
587            return Err(Self::error_from_envelope(err));
588        }
589        let items = Self::parse_symbols(&resp);
590        self.last_meta = Self::parse_truncation(&resp, items.len() as u32);
591        Ok(items)
592    }
593
594    fn inspections(&mut self, uri: &Uri) -> Result<Vec<InspectionDiag>, String> {
595        let body = self.path_body(uri);
596        let resp = self.post("/inspections", &body)?;
597        if let Some(err) = resp.get("error") {
598            return Err(Self::error_from_envelope(err));
599        }
600        let diags = Self::parse_inspections(&resp);
601        self.last_meta = Self::parse_truncation(&resp, diags.len() as u32);
602        Ok(diags)
603    }
604
605    fn list_inspections(&mut self) -> Result<Vec<InspectionInfo>, String> {
606        let resp = self.post("/list_inspections", &serde_json::json!({ "path": "" }))?;
607        if let Some(err) = resp.get("error") {
608            return Err(Self::error_from_envelope(err));
609        }
610        let items = Self::parse_inspection_list(&resp);
611        self.last_meta = Self::parse_truncation(&resp, items.len() as u32);
612        Ok(items)
613    }
614
615    fn replace_symbol_body(&mut self, edit: &RangeEdit) -> Result<EditResult, String> {
616        self.post_edit("/replaceSymbolBody", edit)
617    }
618
619    fn insert_before_symbol(&mut self, edit: &RangeEdit) -> Result<EditResult, String> {
620        self.post_edit("/insertBeforeSymbol", edit)
621    }
622
623    fn insert_after_symbol(&mut self, edit: &RangeEdit) -> Result<EditResult, String> {
624        self.post_edit("/insertAfterSymbol", edit)
625    }
626
627    fn rename_preview(
628        &mut self,
629        req: &crate::lsp::backend::RenameQuery,
630    ) -> Result<crate::lsp::backend::RenamePlan, String> {
631        let mut body = Self::rename_body(&req.rel_path, req.target_range, &req.new_name);
632        body["search_comments"] = serde_json::json!(req.search_comments);
633        body["search_text_occurrences"] = serde_json::json!(req.search_text_occurrences);
634        let resp = self.post("/renamePreview", &body)?;
635        if let Some(err) = resp.get("error") {
636            return Err(Self::error_from_envelope(err));
637        }
638        Ok(Self::parse_rename_plan(&resp))
639    }
640
641    fn rename_apply(
642        &mut self,
643        req: &crate::lsp::backend::RenameApply,
644    ) -> Result<crate::lsp::backend::RenameResult, String> {
645        let mut body = Self::rename_body(&req.rel_path, req.target_range, &req.new_name);
646        body["force"] = serde_json::json!(req.force);
647        let resp = self.post("/renameApply", &body)?;
648        if let Some(err) = resp.get("error") {
649            return Err(Self::error_from_envelope(err));
650        }
651        Ok(Self::parse_apply_result(&resp))
652    }
653
654    fn move_preview(
655        &mut self,
656        req: &crate::lsp::backend::MoveQuery,
657    ) -> Result<crate::lsp::backend::RenamePlan, String> {
658        let body = Self::move_body(&req.rel_path, req.src_range, &req.target);
659        let resp = self.post("/movePreview", &body)?;
660        if let Some(err) = resp.get("error") {
661            return Err(Self::error_from_envelope(err));
662        }
663        Ok(Self::parse_rename_plan(&resp))
664    }
665
666    fn move_apply(
667        &mut self,
668        req: &crate::lsp::backend::MoveApply,
669    ) -> Result<crate::lsp::backend::RenameResult, String> {
670        let mut body = Self::move_body(&req.query.rel_path, req.query.src_range, &req.query.target);
671        body["force"] = serde_json::json!(req.force);
672        let resp = self.post("/moveApply", &body)?;
673        if let Some(err) = resp.get("error") {
674            return Err(Self::error_from_envelope(err));
675        }
676        Ok(Self::parse_apply_result(&resp))
677    }
678
679    fn safe_delete_preview(
680        &mut self,
681        req: &crate::lsp::backend::SafeDeleteQuery,
682    ) -> Result<crate::lsp::backend::RenamePlan, String> {
683        let body = Self::safe_delete_body(&req.rel_path, req.src_range, false, false);
684        let resp = self.post("/safeDeletePreview", &body)?;
685        if let Some(err) = resp.get("error") {
686            return Err(Self::error_from_envelope(err));
687        }
688        Ok(Self::parse_rename_plan(&resp))
689    }
690
691    fn safe_delete_apply(
692        &mut self,
693        req: &crate::lsp::backend::SafeDeleteApply,
694    ) -> Result<crate::lsp::backend::RenameResult, String> {
695        let body = Self::safe_delete_body(
696            &req.query.rel_path,
697            req.query.src_range,
698            req.force,
699            req.propagate,
700        );
701        let resp = self.post("/safeDeleteApply", &body)?;
702        if let Some(err) = resp.get("error") {
703            return Err(Self::error_from_envelope(err));
704        }
705        Ok(Self::parse_apply_result(&resp))
706    }
707
708    fn inline_preview(
709        &mut self,
710        req: &crate::lsp::backend::InlineQuery,
711    ) -> Result<crate::lsp::backend::RenamePlan, String> {
712        let body = Self::inline_body(&req.rel_path, req.src_range, req.keep_definition);
713        let resp = self.post("/inlinePreview", &body)?;
714        if let Some(err) = resp.get("error") {
715            return Err(Self::error_from_envelope(err));
716        }
717        Ok(Self::parse_rename_plan(&resp))
718    }
719
720    fn inline_apply(
721        &mut self,
722        req: &crate::lsp::backend::InlineApply,
723    ) -> Result<crate::lsp::backend::RenameResult, String> {
724        let body = Self::inline_body(
725            &req.query.rel_path,
726            req.query.src_range,
727            req.query.keep_definition,
728        );
729        let resp = self.post("/inlineApply", &body)?;
730        if let Some(err) = resp.get("error") {
731            return Err(Self::error_from_envelope(err));
732        }
733        Ok(Self::parse_apply_result(&resp))
734    }
735
736    fn reformat(
737        &mut self,
738        req: &crate::lsp::backend::ReformatQuery,
739    ) -> Result<crate::lsp::backend::ReformatResult, String> {
740        let body = Self::reformat_body(&req.rel_path, &req.scope, req.optimize_imports);
741        let resp = self.post("/reformat", &body)?;
742        if let Some(err) = resp.get("error") {
743            return Err(Self::error_from_envelope(err));
744        }
745        Ok(Self::parse_reformat_result(&resp))
746    }
747
748    fn rename(
749        &mut self,
750        _uri: &Uri,
751        _position: Position,
752        _new_name: &str,
753    ) -> Result<Option<WorkspaceEdit>, String> {
754        // Symbolic edits are v2 (spec §9 v2-Ausblick). Phase 1 skeleton: not yet.
755        Err("rename via JetBrains backend is not implemented yet (v2 edit spec)".to_string())
756    }
757
758    fn is_stale(&self, project_root: &str) -> bool {
759        // Cheap re-check: port file gone, or pid/port changed (IDE restarted),
760        // or our cached pid is dead → stale. NO HTTP (health is not pinged per call).
761        match crate::lsp::port_discovery::read_port_file(project_root) {
762            Some(pf) => {
763                pf.pid != self.pid
764                    || pf.port != self.port
765                    || !crate::lsp::port_discovery::pid_alive(self.pid)
766            }
767            None => true,
768        }
769    }
770
771    fn last_truncation(&self) -> Option<crate::lsp::backend::Truncation> {
772        self.last_meta
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use std::io::{Read, Write};
780    use std::net::TcpListener;
781
782    /// Spins up a one-shot TCP server returning a canned HTTP/JSON response,
783    /// so we can assert the wire→Location mapping without a real IDE.
784    fn mock_once(json_body: &'static str) -> u16 {
785        // Advertised request body size, so we can fully drain the request before
786        // replying. On Windows, dropping a socket that still holds unconsumed
787        // inbound bytes makes the OS RST the connection (os error 10053 / 10054),
788        // which aborts the client mid-response: the request line + headers + body
789        // must all be read first.
790        fn content_length(headers: &[u8]) -> usize {
791            let text = String::from_utf8_lossy(headers);
792            for line in text.lines() {
793                let lower = line.to_ascii_lowercase();
794                if let Some(v) = lower.strip_prefix("content-length:") {
795                    return v.trim().parse().unwrap_or(0);
796                }
797            }
798            0
799        }
800
801        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
802        let port = listener.local_addr().unwrap().port();
803        std::thread::spawn(move || {
804            if let Ok((mut stream, _)) = listener.accept() {
805                let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10)));
806                let mut req: Vec<u8> = Vec::with_capacity(2048);
807                let mut buf = [0u8; 2048];
808                while let Ok(n) = stream.read(&mut buf) {
809                    if n == 0 {
810                        break;
811                    }
812                    req.extend_from_slice(&buf[..n]);
813                    if let Some(pos) = req.windows(4).position(|w| w == b"\r\n\r\n") {
814                        let body_have = req.len() - (pos + 4);
815                        if body_have >= content_length(&req[..pos]) {
816                            break; // full request consumed
817                        }
818                    }
819                }
820                let resp = format!(
821                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
822                    json_body.len(),
823                    json_body
824                );
825                let _ = stream.write_all(resp.as_bytes());
826                let _ = stream.flush();
827                // Half-close and wait for the client to finish reading + close, so
828                // the full response reaches it before the socket is dropped.
829                let _ = stream.shutdown(std::net::Shutdown::Write);
830                let _ = stream.read(&mut buf);
831            }
832        });
833        port
834    }
835
836    #[test]
837    fn references_parses_wire_locations() {
838        let body = r#"{"locations":[{"path":"src/main.rs","range":{"start":{"line":5,"character":13},"end":{"line":5,"character":18}}}]}"#;
839        let port = mock_once(body);
840        let mut backend = JetBrainsHttpBackend::new(
841            port,
842            "tok".to_string(),
843            "/proj".to_string(),
844            std::process::id(),
845        );
846        let uri = file_path_to_uri("/proj/src/main.rs").unwrap();
847        let locs = backend
848            .references(
849                &uri,
850                Position {
851                    line: 5,
852                    character: 13,
853                },
854                "project",
855            )
856            .expect("should parse");
857        assert_eq!(locs.len(), 1);
858        assert_eq!(locs[0].range.start.line, 5);
859        assert_eq!(locs[0].range.start.character, 13);
860        assert!(locs[0].uri.as_str().ends_with("/proj/src/main.rs"));
861    }
862
863    #[test]
864    fn type_hierarchy_parses_wire_tree() {
865        use crate::lsp::backend::HierarchyDirection;
866        let body = r#"{"tree":{"name":"Animal","path":"A.kt","line":1,"children":[{"name":"Dog","path":"A.kt","line":2,"children":[]}]},"truncated":false}"#;
867        let port = mock_once(body);
868        let mut backend = JetBrainsHttpBackend::new(
869            port,
870            "tok".to_string(),
871            "/proj".to_string(),
872            std::process::id(),
873        );
874        let uri = file_path_to_uri("/proj/A.kt").unwrap();
875        let tree = backend
876            .type_hierarchy(
877                &uri,
878                Position {
879                    line: 0,
880                    character: 0,
881                },
882                HierarchyDirection::Subtypes,
883            )
884            .expect("should parse");
885        assert_eq!(tree.name, "Animal");
886        assert_eq!(tree.line, 1);
887        assert_eq!(tree.children.len(), 1);
888        assert_eq!(tree.children[0].name, "Dog");
889        assert_eq!(tree.children[0].path, "A.kt");
890    }
891
892    #[test]
893    fn symbols_overview_parses_wire_items() {
894        let body = r#"{"symbols":[{"name":"Animal","kind":"interface","line":1},{"name":"main","kind":"function","line":9}],"truncated":false,"total":2}"#;
895        let port = mock_once(body);
896        let mut backend = JetBrainsHttpBackend::new(
897            port,
898            "tok".to_string(),
899            "/proj".to_string(),
900            std::process::id(),
901        );
902        let uri = file_path_to_uri("/proj/A.kt").unwrap();
903        let items = backend.symbols_overview(&uri).expect("should parse");
904        assert_eq!(items.len(), 2);
905        assert_eq!(items[0].kind, "interface");
906        assert_eq!(items[1].name, "main");
907        assert_eq!(items[1].line, 9);
908    }
909
910    #[test]
911    fn inspections_parses_wire_diags() {
912        let body = r#"{"diagnostics":[{"path":"A.kt","line":3,"severity":"WARNING","message":"unused variable"}],"truncated":false,"total":1}"#;
913        let port = mock_once(body);
914        let mut backend = JetBrainsHttpBackend::new(
915            port,
916            "tok".to_string(),
917            "/proj".to_string(),
918            std::process::id(),
919        );
920        let uri = file_path_to_uri("/proj/A.kt").unwrap();
921        let diags = backend.inspections(&uri).expect("should parse");
922        assert_eq!(diags.len(), 1);
923        assert_eq!(diags[0].path, "A.kt");
924        assert_eq!(diags[0].line, 3);
925        assert_eq!(diags[0].severity, "WARNING");
926        assert_eq!(diags[0].message, "unused variable");
927    }
928
929    #[test]
930    fn replace_symbol_body_parses_wire_result() {
931        let port = mock_once(
932            r#"{"applied":true,
933                "newRange":{"start":{"line":1,"character":0},"end":{"line":1,"character":3}},
934                "editedText":"NEW"}"#,
935        );
936        let mut be = JetBrainsHttpBackend::new(port, "tok".into(), "/tmp/proj".to_string(), 1234);
937        let edit = crate::lsp::backend::RangeEdit {
938            abs_path: "/tmp/proj/Foo.kt".into(),
939            rel_path: "Foo.kt".into(),
940            range: crate::lsp::backend::TextRange0Based {
941                start_line: 1,
942                start_char: 0,
943                end_line: 1,
944                end_char: 4,
945            },
946            text: "NEW".into(),
947            expected_hash: None,
948        };
949        let res = be.replace_symbol_body(&edit).unwrap();
950        assert!(res.applied);
951        assert_eq!(res.edited_text, "NEW");
952        assert_eq!(res.new_range.end_char, 3);
953    }
954
955    #[test]
956    fn edit_maps_error_envelope_to_err() {
957        let port = mock_once(r#"{"error":{"code":"CONFLICT","message":"stale"}}"#);
958        let mut be = JetBrainsHttpBackend::new(port, "tok".into(), "/tmp/proj".to_string(), 1234);
959        let edit = crate::lsp::backend::RangeEdit {
960            abs_path: "/tmp/proj/Foo.kt".into(),
961            rel_path: "Foo.kt".into(),
962            range: crate::lsp::backend::TextRange0Based {
963                start_line: 0,
964                start_char: 0,
965                end_line: 0,
966                end_char: 0,
967            },
968            text: "x".into(),
969            expected_hash: None,
970        };
971        assert_eq!(
972            be.replace_symbol_body(&edit).unwrap_err(),
973            "CONFLICT: stale"
974        );
975    }
976
977    #[test]
978    fn inspections_maps_error_envelope_to_err() {
979        let body = r#"{"error":{"code":"UNSUPPORTED_LANGUAGE","message":"only kotlin"}}"#;
980        let port = mock_once(body);
981        let mut backend = JetBrainsHttpBackend::new(
982            port,
983            "tok".to_string(),
984            "/proj".to_string(),
985            std::process::id(),
986        );
987        let uri = file_path_to_uri("/proj/A.kt").unwrap();
988        let err = backend.inspections(&uri).expect_err("envelope → Err");
989        assert_eq!(err, "UNSUPPORTED_LANGUAGE: only kotlin");
990    }
991
992    #[test]
993    fn list_inspections_parses_wire_items() {
994        let body = r#"{"inspections":[{"id":"UnusedSymbol","name":"Unused declaration","severity":"WARNING"}],"truncated":true,"total":342}"#;
995        let port = mock_once(body);
996        let mut backend = JetBrainsHttpBackend::new(
997            port,
998            "tok".to_string(),
999            "/proj".to_string(),
1000            std::process::id(),
1001        );
1002        let items = backend.list_inspections().expect("should parse");
1003        assert_eq!(items.len(), 1);
1004        assert_eq!(items[0].id, "UnusedSymbol");
1005        assert_eq!(items[0].name, "Unused declaration");
1006        assert_eq!(items[0].severity, "WARNING");
1007        let meta = backend.last_truncation().expect("meta recorded");
1008        assert!(meta.truncated);
1009        assert_eq!(meta.total, 342);
1010    }
1011
1012    #[test]
1013    fn references_records_truncation_meta() {
1014        let body = r#"{"locations":[{"path":"a.rs","range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}}}],"truncated":true,"total":742}"#;
1015        let port = mock_once(body);
1016        let mut backend = JetBrainsHttpBackend::new(
1017            port,
1018            "tok".to_string(),
1019            "/proj".to_string(),
1020            std::process::id(),
1021        );
1022        let uri = file_path_to_uri("/proj/a.rs").unwrap();
1023        let _ = backend
1024            .references(
1025                &uri,
1026                Position {
1027                    line: 0,
1028                    character: 0,
1029                },
1030                "project",
1031            )
1032            .unwrap();
1033        let meta = backend.last_truncation().expect("meta recorded");
1034        assert!(meta.truncated);
1035        assert_eq!(meta.total, 742);
1036    }
1037
1038    #[test]
1039    fn is_stale_true_when_no_port_file() {
1040        // Unlikely root → no port file → cached backend is stale.
1041        let backend = JetBrainsHttpBackend::new(
1042            12345,
1043            "tok".to_string(),
1044            "/nonexistent/leanctx/proj/xyz".to_string(),
1045            999_999_999,
1046        );
1047        assert!(backend.is_stale("/nonexistent/leanctx/proj/xyz"));
1048    }
1049
1050    #[test]
1051    fn is_stale_false_for_matching_live_pid() {
1052        let _lock = crate::core::data_dir::test_env_lock();
1053        // A port file describing THIS process (pid alive) + matching port/token
1054        // must be considered fresh. We stage a port file via the data-dir env.
1055        let tmp = std::env::temp_dir().join(format!("leanctx-stale-{}", std::process::id()));
1056        std::fs::create_dir_all(&tmp).unwrap();
1057        let root = tmp.to_string_lossy().to_string();
1058        // Write a port file at the discovery path for `root`.
1059        crate::test_env::set_var("LEAN_CTX_DATA_DIR", &tmp);
1060        let pf_path = crate::lsp::port_discovery::port_file_path(&root).unwrap();
1061        let pid = std::process::id();
1062        // Serialize via serde so the path is JSON-escaped. On Windows `root`
1063        // contains backslashes (C:\...\Temp\...), which are invalid raw JSON string
1064        // escapes — hand-built JSON would fail to parse and read_port_file would
1065        // return None, making is_stale wrongly report "stale".
1066        std::fs::write(
1067            &pf_path,
1068            serde_json::json!({
1069                "port": 4567,
1070                "token": "tok",
1071                "pid": pid,
1072                "project_root": root,
1073                "ide_version": "x",
1074            })
1075            .to_string(),
1076        )
1077        .unwrap();
1078        let backend = JetBrainsHttpBackend::new(4567, "tok".to_string(), root.clone(), pid);
1079        assert!(
1080            !backend.is_stale(&root),
1081            "matching live pid+port must be fresh"
1082        );
1083        // Different cached pid → stale even though the file is live.
1084        let other = JetBrainsHttpBackend::new(4567, "tok".to_string(), root.clone(), pid + 1);
1085        assert!(other.is_stale(&root), "pid mismatch must be stale");
1086        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1087        let _ = std::fs::remove_dir_all(&tmp);
1088    }
1089
1090    #[test]
1091    fn canonical_root_strips_trailing_slash_and_resolves_realpath() {
1092        // Existing dir with a trailing slash → canonical form has no trailing slash
1093        // and matches sha2's canonicalize (port_discovery::project_hash parity).
1094        let tmp = std::env::temp_dir();
1095        let with_slash = format!("{}/", tmp.to_string_lossy());
1096        let backend =
1097            JetBrainsHttpBackend::new(1, "t".to_string(), with_slash.clone(), std::process::id());
1098        let expected = std::fs::canonicalize(&tmp)
1099            .unwrap()
1100            .to_string_lossy()
1101            .to_string();
1102        assert_eq!(backend.project_root_for_test(), expected);
1103        assert!(!backend.project_root_for_test().ends_with('/'));
1104    }
1105
1106    #[test]
1107    fn canonical_root_falls_back_to_raw_for_nonexistent() {
1108        let raw = "/nonexistent/leanctx/xyz";
1109        let backend =
1110            JetBrainsHttpBackend::new(1, "t".to_string(), raw.to_string(), std::process::id());
1111        assert_eq!(backend.project_root_for_test(), raw);
1112    }
1113
1114    #[test]
1115    fn rename_preview_parses_usages_and_conflicts() {
1116        let body = r#"{"usages":[
1117            {"path":"src/a.rs","range":{"start":{"line":5,"character":4},"end":{"line":5,"character":7}},"context":"foo()"},
1118            {"path":"src/b.rs","range":{"start":{"line":1,"character":0},"end":{"line":1,"character":3}}}
1119          ],"conflicts":[
1120            {"path":"src/a.rs","range":{"start":{"line":9,"character":0},"end":{"line":9,"character":3}},"message":"name clash"}
1121          ]}"#;
1122        let port = mock_once(body);
1123        let mut be = JetBrainsHttpBackend::new(port, "tok".into(), "/proj".to_string(), 1234);
1124        let q = crate::lsp::backend::RenameQuery {
1125            abs_path: "/proj/src/a.rs".into(),
1126            rel_path: "src/a.rs".into(),
1127            target_range: crate::lsp::backend::TextRange0Based {
1128                start_line: 5,
1129                start_char: 4,
1130                end_line: 5,
1131                end_char: 7,
1132            },
1133            new_name: "bar".into(),
1134            search_comments: false,
1135            search_text_occurrences: false,
1136        };
1137        let plan = be.rename_preview(&q).unwrap();
1138        assert_eq!(plan.usages.len(), 2);
1139        assert_eq!(plan.usages[0].path, "src/a.rs");
1140        assert_eq!(plan.usages[0].context.as_deref(), Some("foo()"));
1141        assert_eq!(plan.usages[1].context, None);
1142        assert_eq!(plan.conflicts.len(), 1);
1143        assert_eq!(plan.conflicts[0].message, "name clash");
1144    }
1145
1146    #[test]
1147    fn rename_preview_maps_error_envelope() {
1148        let port = mock_once(r#"{"error":{"code":"INDEXING","message":"busy"}}"#);
1149        let mut be = JetBrainsHttpBackend::new(port, "tok".into(), "/proj".to_string(), 1234);
1150        let q = crate::lsp::backend::RenameQuery {
1151            abs_path: "/proj/a.rs".into(),
1152            rel_path: "a.rs".into(),
1153            target_range: crate::lsp::backend::TextRange0Based {
1154                start_line: 0,
1155                start_char: 0,
1156                end_line: 0,
1157                end_char: 1,
1158            },
1159            new_name: "y".into(),
1160            search_comments: false,
1161            search_text_occurrences: false,
1162        };
1163        assert_eq!(be.rename_preview(&q).unwrap_err(), "INDEXING: busy");
1164    }
1165
1166    #[test]
1167    fn rename_apply_parses_changed_paths() {
1168        let body = r#"{"applied":true,"changed_paths":["src/a.rs","src/b.rs"]}"#;
1169        let port = mock_once(body);
1170        let mut be = JetBrainsHttpBackend::new(port, "tok".into(), "/proj".to_string(), 1234);
1171        let a = crate::lsp::backend::RenameApply {
1172            abs_path: "/proj/src/a.rs".into(),
1173            rel_path: "src/a.rs".into(),
1174            target_range: crate::lsp::backend::TextRange0Based {
1175                start_line: 5,
1176                start_char: 4,
1177                end_line: 5,
1178                end_char: 7,
1179            },
1180            new_name: "bar".into(),
1181            force: false,
1182        };
1183        let res = be.rename_apply(&a).unwrap();
1184        assert!(res.applied);
1185        assert_eq!(res.changed_paths, vec!["src/a.rs", "src/b.rs"]);
1186    }
1187
1188    #[test]
1189    fn move_body_path_and_parent_variants() {
1190        use crate::lsp::backend::{MoveTarget, TextRange0Based};
1191        let r = TextRange0Based {
1192            start_line: 2,
1193            start_char: 0,
1194            end_line: 2,
1195            end_char: 12,
1196        };
1197
1198        let path_body = JetBrainsHttpBackend::move_body(
1199            "Widget.kt",
1200            r,
1201            &MoveTarget::Path {
1202                abs_path: "/p/app/moved".into(),
1203                rel_path: "app/moved".into(),
1204            },
1205        );
1206        assert_eq!(path_body["path"], "Widget.kt");
1207        assert_eq!(path_body["target"]["kind"], "path");
1208        assert_eq!(path_body["target"]["path"], "app/moved");
1209        assert!(path_body["target"].get("range").is_none());
1210
1211        let pr = TextRange0Based {
1212            start_line: 0,
1213            start_char: 0,
1214            end_line: 5,
1215            end_char: 1,
1216        };
1217        let parent_body = JetBrainsHttpBackend::move_body(
1218            "Widget.kt",
1219            r,
1220            &MoveTarget::Parent {
1221                abs_path: "/p/Other.kt".into(),
1222                rel_path: "Other.kt".into(),
1223                range: pr,
1224            },
1225        );
1226        assert_eq!(parent_body["target"]["kind"], "parent");
1227        assert_eq!(parent_body["target"]["path"], "Other.kt");
1228        assert_eq!(parent_body["target"]["range"]["start"]["line"], 0);
1229        assert_eq!(parent_body["target"]["range"]["end"]["line"], 5);
1230    }
1231
1232    #[test]
1233    fn safe_delete_body_carries_flags() {
1234        use crate::lsp::backend::TextRange0Based;
1235        let r = TextRange0Based {
1236            start_line: 2,
1237            start_char: 0,
1238            end_line: 2,
1239            end_char: 12,
1240        };
1241        let body = JetBrainsHttpBackend::safe_delete_body("Widget.kt", r, true, false);
1242        assert_eq!(body["path"], "Widget.kt");
1243        assert_eq!(body["range"]["start"]["line"], 2);
1244        assert_eq!(body["force"], true);
1245        assert_eq!(body["propagate"], false);
1246    }
1247
1248    #[test]
1249    fn inline_body_carries_keep_definition() {
1250        let r = crate::lsp::backend::TextRange0Based {
1251            start_line: 2,
1252            start_char: 4,
1253            end_line: 2,
1254            end_char: 7,
1255        };
1256        let body = JetBrainsHttpBackend::inline_body("Calc.kt", r, true);
1257        assert_eq!(body["path"], "Calc.kt");
1258        assert_eq!(body["keep_definition"], true);
1259        assert_eq!(body["range"]["start"]["line"], 2);
1260    }
1261
1262    #[test]
1263    fn reformat_body_encodes_scope_variants() {
1264        use crate::lsp::backend::{ReformatScope, TextRange0Based};
1265        let file = JetBrainsHttpBackend::reformat_body("M.kt", &ReformatScope::File, true);
1266        assert_eq!(file["scope"]["kind"], "file");
1267        assert_eq!(file["optimize_imports"], true);
1268        let region = JetBrainsHttpBackend::reformat_body(
1269            "M.kt",
1270            &ReformatScope::Region {
1271                range: TextRange0Based {
1272                    start_line: 9,
1273                    start_char: 0,
1274                    end_line: 19,
1275                    end_char: 0,
1276                },
1277            },
1278            false,
1279        );
1280        assert_eq!(region["scope"]["kind"], "region");
1281        assert_eq!(region["scope"]["range"]["start"]["line"], 9);
1282        let sym = JetBrainsHttpBackend::reformat_body(
1283            "M.kt",
1284            &ReformatScope::Symbol {
1285                range: TextRange0Based {
1286                    start_line: 3,
1287                    start_char: 0,
1288                    end_line: 5,
1289                    end_char: 1,
1290                },
1291            },
1292            false,
1293        );
1294        assert_eq!(sym["scope"]["kind"], "symbol");
1295    }
1296
1297    #[test]
1298    fn parse_reformat_result_reads_changed_paths() {
1299        let v = serde_json::json!({ "applied": true, "changed_paths": ["M.kt"] });
1300        let r = JetBrainsHttpBackend::parse_reformat_result(&v);
1301        assert!(r.applied);
1302        assert_eq!(r.changed_paths, vec!["M.kt".to_string()]);
1303    }
1304}