1use 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 project_root: String,
24 pid: u32,
26 port: u16,
28 last_meta: Option<crate::lsp::backend::Truncation>,
31}
32
33impl JetBrainsHttpBackend {
34 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)] 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 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 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(), }
244 }
245
246 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn mock_once(json_body: &'static str) -> u16 {
785 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; }
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 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 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 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 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 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 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 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}