use std::time::Duration;
use lsp_types::{GotoDefinitionResponse, Location, Position, Range, Uri, WorkspaceEdit};
use serde_json::Value;
use crate::lsp::backend::{
EditResult, HierarchyDirection, InspectionDiag, InspectionInfo, LspBackend, RangeEdit,
SymbolOverviewItem, TextRange0Based, TypeHierarchyNode,
};
use crate::lsp::client::file_path_to_uri;
const REQUEST_TIMEOUT_SECS: u64 = 30;
pub struct JetBrainsHttpBackend {
base_url: String,
token: String,
project_root: String,
pid: u32,
port: u16,
last_meta: Option<crate::lsp::backend::Truncation>,
}
impl JetBrainsHttpBackend {
fn canonical_root(project_root: &str) -> String {
let canonical = std::fs::canonicalize(project_root).map_or_else(
|_| project_root.to_string(),
|p| p.to_string_lossy().to_string(),
);
canonical
.strip_suffix('/')
.unwrap_or(&canonical)
.to_string()
}
#[allow(clippy::needless_pass_by_value)] pub fn new(port: u16, token: String, project_root: String, pid: u32) -> Self {
Self {
base_url: format!("http://127.0.0.1:{port}"),
token,
project_root: Self::canonical_root(&project_root),
pid,
port,
last_meta: None,
}
}
#[cfg(test)]
fn project_root_for_test(&self) -> &str {
&self.project_root
}
fn post(&self, endpoint: &str, body: &Value) -> Result<Value, String> {
let url = format!("{}{endpoint}", self.base_url);
let payload = serde_json::to_vec(body).map_err(|e| format!("serialize request: {e}"))?;
let resp = ureq::post(&url)
.config()
.timeout_global(Some(Duration::from_secs(REQUEST_TIMEOUT_SECS)))
.build()
.header("X-LeanCtx-Token", &self.token)
.header("Content-Type", "application/json")
.send(payload.as_slice())
.map_err(|e| format!("JetBrains backend request to {endpoint} failed: {e}"))?;
let text = resp
.into_body()
.read_to_string()
.map_err(|e| format!("JetBrains backend: read response: {e}"))?;
serde_json::from_str(&text).map_err(|e| format!("JetBrains backend: parse response: {e}"))
}
fn rel_to_uri(&self, rel: &str) -> Option<Uri> {
let abs = format!("{}/{}", self.project_root, rel);
file_path_to_uri(&abs).ok()
}
fn parse_position(v: &Value) -> Option<Position> {
let line = v.get("line")?.as_u64()? as u32;
let character = v.get("character")?.as_u64()? as u32;
Some(Position { line, character })
}
fn parse_locations(&self, v: &Value) -> Vec<Location> {
v.get("locations")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|loc| {
let rel = loc.get("path")?.as_str()?;
let uri = self.rel_to_uri(rel)?;
let range = loc.get("range")?;
let start = Self::parse_position(range.get("start")?)?;
let end = Self::parse_position(range.get("end")?)?;
Some(Location {
uri,
range: Range { start, end },
})
})
.collect()
})
.unwrap_or_default()
}
fn parse_type_hierarchy(v: &Value) -> TypeHierarchyNode {
fn node(v: &Value) -> TypeHierarchyNode {
TypeHierarchyNode {
name: v
.get("name")
.and_then(Value::as_str)
.unwrap_or("?")
.to_string(),
path: v
.get("path")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
line: v.get("line").and_then(Value::as_u64).unwrap_or(0) as u32,
children: v
.get("children")
.and_then(Value::as_array)
.map(|arr| arr.iter().map(node).collect())
.unwrap_or_default(),
}
}
v.get("tree").map_or_else(
|| TypeHierarchyNode {
name: String::new(),
path: String::new(),
line: 0,
children: vec![],
},
node,
)
}
fn parse_symbols(v: &Value) -> Vec<SymbolOverviewItem> {
v.get("symbols")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|s| {
Some(SymbolOverviewItem {
name: s.get("name")?.as_str()?.to_string(),
kind: s.get("kind")?.as_str()?.to_string(),
line: s.get("line")?.as_u64()? as u32,
})
})
.collect()
})
.unwrap_or_default()
}
fn parse_inspections(v: &Value) -> Vec<InspectionDiag> {
v.get("diagnostics")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|d| {
Some(InspectionDiag {
path: d.get("path")?.as_str()?.to_string(),
line: d.get("line")?.as_u64()? as u32,
severity: d.get("severity")?.as_str()?.to_string(),
message: d.get("message")?.as_str()?.to_string(),
})
})
.collect()
})
.unwrap_or_default()
}
fn parse_inspection_list(v: &Value) -> Vec<InspectionInfo> {
v.get("inspections")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|i| {
Some(InspectionInfo {
id: i.get("id")?.as_str()?.to_string(),
name: i.get("name")?.as_str()?.to_string(),
severity: i.get("severity")?.as_str()?.to_string(),
})
})
.collect()
})
.unwrap_or_default()
}
fn parse_truncation(v: &Value, shown: u32) -> Option<crate::lsp::backend::Truncation> {
let truncated = v.get("truncated").and_then(Value::as_bool)?;
let total = v
.get("total")
.and_then(Value::as_u64)
.map_or(shown, |n| n as u32);
Some(crate::lsp::backend::Truncation { truncated, total })
}
fn parse_edit_result(v: &Value, fallback_text: &str) -> EditResult {
let pos = |obj: &Value, key: &str| -> (u32, u32) {
let p = obj.get(key);
let line = p
.and_then(|p| p.get("line"))
.and_then(Value::as_u64)
.unwrap_or(0) as u32;
let ch = p
.and_then(|p| p.get("character"))
.and_then(Value::as_u64)
.unwrap_or(0) as u32;
(line, ch)
};
let nr = v.get("newRange");
let (sl, sc) = nr.map_or((0, 0), |r| pos(r, "start"));
let (el, ec) = nr.map_or((0, 0), |r| pos(r, "end"));
EditResult {
applied: v.get("applied").and_then(Value::as_bool).unwrap_or(false),
new_range: TextRange0Based {
start_line: sl,
start_char: sc,
end_line: el,
end_char: ec,
},
edited_text: v
.get("editedText")
.and_then(Value::as_str)
.unwrap_or(fallback_text)
.to_string(),
diff: String::new(), }
}
fn path_body(&self, uri: &Uri) -> Value {
let abs = crate::lsp::client::uri_to_file_path(uri).unwrap_or_default();
let rel = abs
.strip_prefix(&self.project_root)
.map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
.unwrap_or(abs);
serde_json::json!({ "path": rel })
}
fn position_body(&self, uri: &Uri, position: Position) -> Value {
let abs = crate::lsp::client::uri_to_file_path(uri).unwrap_or_default();
let rel = abs
.strip_prefix(&self.project_root)
.map(|s| s.strip_prefix('/').unwrap_or(s).to_string())
.unwrap_or(abs);
serde_json::json!({
"path": rel,
"line": position.line,
"character": position.character,
})
}
fn post_edit(&self, endpoint: &str, edit: &RangeEdit) -> Result<EditResult, String> {
let mut body = serde_json::json!({
"path": edit.rel_path,
"range": {
"start": { "line": edit.range.start_line, "character": edit.range.start_char },
"end": { "line": edit.range.end_line, "character": edit.range.end_char },
},
"text": edit.text,
});
if let Some(h) = &edit.expected_hash {
body["expected_hash"] = serde_json::json!(h);
}
let resp = self.post(endpoint, &body)?;
if let Some(err) = resp.get("error") {
return Err(Self::error_from_envelope(err));
}
Ok(Self::parse_edit_result(&resp, &edit.text))
}
fn parse_range0(v: &Value) -> Option<crate::lsp::backend::TextRange0Based> {
let start = Self::parse_position(v.get("start")?)?;
let end = Self::parse_position(v.get("end")?)?;
Some(crate::lsp::backend::TextRange0Based {
start_line: start.line,
start_char: start.character,
end_line: end.line,
end_char: end.character,
})
}
fn parse_rename_plan(v: &Value) -> crate::lsp::backend::RenamePlan {
use crate::lsp::backend::{Conflict, RenamePlan, UsageSite};
let usages = v
.get("usages")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|u| {
Some(UsageSite {
path: u.get("path")?.as_str()?.to_string(),
range: Self::parse_range0(u.get("range")?)?,
context: u.get("context").and_then(Value::as_str).map(String::from),
})
})
.collect()
})
.unwrap_or_default();
let conflicts = v
.get("conflicts")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|c| {
Some(Conflict {
path: c.get("path")?.as_str()?.to_string(),
range: c.get("range").and_then(Self::parse_range0),
message: c.get("message")?.as_str()?.to_string(),
})
})
.collect()
})
.unwrap_or_default();
RenamePlan { usages, conflicts }
}
fn rename_body(
rel_path: &str,
range: crate::lsp::backend::TextRange0Based,
new_name: &str,
) -> Value {
serde_json::json!({
"path": rel_path,
"range": {
"start": { "line": range.start_line, "character": range.start_char },
"end": { "line": range.end_line, "character": range.end_char },
},
"new_name": new_name,
})
}
fn move_body(
rel_path: &str,
src_range: crate::lsp::backend::TextRange0Based,
target: &crate::lsp::backend::MoveTarget,
) -> Value {
use crate::lsp::backend::MoveTarget;
let target_json = match target {
MoveTarget::Path { rel_path: tp, .. } => serde_json::json!({
"kind": "path",
"path": tp,
}),
MoveTarget::Parent {
rel_path: pp,
range,
..
} => serde_json::json!({
"kind": "parent",
"path": pp,
"range": {
"start": { "line": range.start_line, "character": range.start_char },
"end": { "line": range.end_line, "character": range.end_char },
},
}),
};
serde_json::json!({
"path": rel_path,
"range": {
"start": { "line": src_range.start_line, "character": src_range.start_char },
"end": { "line": src_range.end_line, "character": src_range.end_char },
},
"target": target_json,
})
}
fn safe_delete_body(
rel_path: &str,
src_range: crate::lsp::backend::TextRange0Based,
force: bool,
propagate: bool,
) -> Value {
serde_json::json!({
"path": rel_path,
"range": {
"start": { "line": src_range.start_line, "character": src_range.start_char },
"end": { "line": src_range.end_line, "character": src_range.end_char },
},
"force": force,
"propagate": propagate,
})
}
fn parse_apply_result(resp: &Value) -> crate::lsp::backend::RenameResult {
let changed_paths = resp
.get("changed_paths")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|p| p.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
crate::lsp::backend::RenameResult {
applied: resp
.get("applied")
.and_then(Value::as_bool)
.unwrap_or(false),
changed_paths,
}
}
fn error_from_envelope(err: &Value) -> String {
let code = err
.get("code")
.and_then(Value::as_str)
.unwrap_or("INTERNAL");
match err.get("message").and_then(Value::as_str) {
Some(m) if !m.is_empty() => format!("{code}: {m}"),
_ => code.to_string(),
}
}
fn inline_body(
rel_path: &str,
src_range: crate::lsp::backend::TextRange0Based,
keep_definition: bool,
) -> Value {
serde_json::json!({
"path": rel_path,
"range": {
"start": { "line": src_range.start_line, "character": src_range.start_char },
"end": { "line": src_range.end_line, "character": src_range.end_char },
},
"keep_definition": keep_definition,
})
}
fn reformat_body(
rel_path: &str,
scope: &crate::lsp::backend::ReformatScope,
optimize_imports: bool,
) -> Value {
use crate::lsp::backend::ReformatScope;
let scope_json = match scope {
ReformatScope::File => serde_json::json!({ "kind": "file" }),
ReformatScope::Region { range } => serde_json::json!({
"kind": "region",
"range": {
"start": { "line": range.start_line, "character": range.start_char },
"end": { "line": range.end_line, "character": range.end_char },
},
}),
ReformatScope::Symbol { range } => serde_json::json!({
"kind": "symbol",
"range": {
"start": { "line": range.start_line, "character": range.start_char },
"end": { "line": range.end_line, "character": range.end_char },
},
}),
};
serde_json::json!({
"path": rel_path,
"scope": scope_json,
"optimize_imports": optimize_imports,
})
}
fn parse_reformat_result(resp: &Value) -> crate::lsp::backend::ReformatResult {
let changed_paths = resp
.get("changed_paths")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|p| p.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
crate::lsp::backend::ReformatResult {
applied: resp
.get("applied")
.and_then(Value::as_bool)
.unwrap_or(false),
changed_paths,
}
}
}
impl LspBackend for JetBrainsHttpBackend {
fn open_file(&mut self, _uri: &Uri, _language_id: &str, _text: &str) -> Result<(), String> {
Ok(())
}
fn references(
&mut self,
uri: &Uri,
position: Position,
scope: &str,
) -> Result<Vec<Location>, String> {
let mut body = self.position_body(uri, position);
body["scope"] = serde_json::json!(scope);
let resp = self.post("/references", &body)?;
let locs = self.parse_locations(&resp);
self.last_meta = Self::parse_truncation(&resp, locs.len() as u32);
Ok(locs)
}
fn definition(
&mut self,
uri: &Uri,
position: Position,
) -> Result<GotoDefinitionResponse, String> {
let body = self.position_body(uri, position);
let resp = self.post("/definition", &body)?;
Ok(GotoDefinitionResponse::Array(self.parse_locations(&resp)))
}
fn implementations(
&mut self,
uri: &Uri,
position: Position,
scope: &str,
) -> Result<Vec<Location>, String> {
let mut body = self.position_body(uri, position);
body["scope"] = serde_json::json!(scope);
let resp = self.post("/implementations", &body)?;
let locs = self.parse_locations(&resp);
self.last_meta = Self::parse_truncation(&resp, locs.len() as u32);
Ok(locs)
}
fn declaration(&mut self, uri: &Uri, position: Position) -> Result<Vec<Location>, String> {
let body = self.position_body(uri, position);
let resp = self.post("/declaration", &body)?;
Ok(self.parse_locations(&resp))
}
fn type_hierarchy(
&mut self,
uri: &Uri,
position: Position,
direction: HierarchyDirection,
) -> Result<TypeHierarchyNode, String> {
let mut body = self.position_body(uri, position);
body["direction"] = serde_json::json!(match direction {
HierarchyDirection::Supertypes => "supertypes",
HierarchyDirection::Subtypes => "subtypes",
});
let resp = self.post("/type_hierarchy", &body)?;
if let Some(err) = resp.get("error") {
return Err(Self::error_from_envelope(err));
}
self.last_meta = Self::parse_truncation(&resp, 0);
Ok(Self::parse_type_hierarchy(&resp))
}
fn symbols_overview(&mut self, uri: &Uri) -> Result<Vec<SymbolOverviewItem>, String> {
let body = self.path_body(uri);
let resp = self.post("/symbols_overview", &body)?;
if let Some(err) = resp.get("error") {
return Err(Self::error_from_envelope(err));
}
let items = Self::parse_symbols(&resp);
self.last_meta = Self::parse_truncation(&resp, items.len() as u32);
Ok(items)
}
fn inspections(&mut self, uri: &Uri) -> Result<Vec<InspectionDiag>, String> {
let body = self.path_body(uri);
let resp = self.post("/inspections", &body)?;
if let Some(err) = resp.get("error") {
return Err(Self::error_from_envelope(err));
}
let diags = Self::parse_inspections(&resp);
self.last_meta = Self::parse_truncation(&resp, diags.len() as u32);
Ok(diags)
}
fn list_inspections(&mut self) -> Result<Vec<InspectionInfo>, String> {
let resp = self.post("/list_inspections", &serde_json::json!({ "path": "" }))?;
if let Some(err) = resp.get("error") {
return Err(Self::error_from_envelope(err));
}
let items = Self::parse_inspection_list(&resp);
self.last_meta = Self::parse_truncation(&resp, items.len() as u32);
Ok(items)
}
fn replace_symbol_body(&mut self, edit: &RangeEdit) -> Result<EditResult, String> {
self.post_edit("/replaceSymbolBody", edit)
}
fn insert_before_symbol(&mut self, edit: &RangeEdit) -> Result<EditResult, String> {
self.post_edit("/insertBeforeSymbol", edit)
}
fn insert_after_symbol(&mut self, edit: &RangeEdit) -> Result<EditResult, String> {
self.post_edit("/insertAfterSymbol", edit)
}
fn rename_preview(
&mut self,
req: &crate::lsp::backend::RenameQuery,
) -> Result<crate::lsp::backend::RenamePlan, String> {
let mut body = Self::rename_body(&req.rel_path, req.target_range, &req.new_name);
body["search_comments"] = serde_json::json!(req.search_comments);
body["search_text_occurrences"] = serde_json::json!(req.search_text_occurrences);
let resp = self.post("/renamePreview", &body)?;
if let Some(err) = resp.get("error") {
return Err(Self::error_from_envelope(err));
}
Ok(Self::parse_rename_plan(&resp))
}
fn rename_apply(
&mut self,
req: &crate::lsp::backend::RenameApply,
) -> Result<crate::lsp::backend::RenameResult, String> {
let mut body = Self::rename_body(&req.rel_path, req.target_range, &req.new_name);
body["force"] = serde_json::json!(req.force);
let resp = self.post("/renameApply", &body)?;
if let Some(err) = resp.get("error") {
return Err(Self::error_from_envelope(err));
}
Ok(Self::parse_apply_result(&resp))
}
fn move_preview(
&mut self,
req: &crate::lsp::backend::MoveQuery,
) -> Result<crate::lsp::backend::RenamePlan, String> {
let body = Self::move_body(&req.rel_path, req.src_range, &req.target);
let resp = self.post("/movePreview", &body)?;
if let Some(err) = resp.get("error") {
return Err(Self::error_from_envelope(err));
}
Ok(Self::parse_rename_plan(&resp))
}
fn move_apply(
&mut self,
req: &crate::lsp::backend::MoveApply,
) -> Result<crate::lsp::backend::RenameResult, String> {
let mut body = Self::move_body(&req.query.rel_path, req.query.src_range, &req.query.target);
body["force"] = serde_json::json!(req.force);
let resp = self.post("/moveApply", &body)?;
if let Some(err) = resp.get("error") {
return Err(Self::error_from_envelope(err));
}
Ok(Self::parse_apply_result(&resp))
}
fn safe_delete_preview(
&mut self,
req: &crate::lsp::backend::SafeDeleteQuery,
) -> Result<crate::lsp::backend::RenamePlan, String> {
let body = Self::safe_delete_body(&req.rel_path, req.src_range, false, false);
let resp = self.post("/safeDeletePreview", &body)?;
if let Some(err) = resp.get("error") {
return Err(Self::error_from_envelope(err));
}
Ok(Self::parse_rename_plan(&resp))
}
fn safe_delete_apply(
&mut self,
req: &crate::lsp::backend::SafeDeleteApply,
) -> Result<crate::lsp::backend::RenameResult, String> {
let body = Self::safe_delete_body(
&req.query.rel_path,
req.query.src_range,
req.force,
req.propagate,
);
let resp = self.post("/safeDeleteApply", &body)?;
if let Some(err) = resp.get("error") {
return Err(Self::error_from_envelope(err));
}
Ok(Self::parse_apply_result(&resp))
}
fn inline_preview(
&mut self,
req: &crate::lsp::backend::InlineQuery,
) -> Result<crate::lsp::backend::RenamePlan, String> {
let body = Self::inline_body(&req.rel_path, req.src_range, req.keep_definition);
let resp = self.post("/inlinePreview", &body)?;
if let Some(err) = resp.get("error") {
return Err(Self::error_from_envelope(err));
}
Ok(Self::parse_rename_plan(&resp))
}
fn inline_apply(
&mut self,
req: &crate::lsp::backend::InlineApply,
) -> Result<crate::lsp::backend::RenameResult, String> {
let body = Self::inline_body(
&req.query.rel_path,
req.query.src_range,
req.query.keep_definition,
);
let resp = self.post("/inlineApply", &body)?;
if let Some(err) = resp.get("error") {
return Err(Self::error_from_envelope(err));
}
Ok(Self::parse_apply_result(&resp))
}
fn reformat(
&mut self,
req: &crate::lsp::backend::ReformatQuery,
) -> Result<crate::lsp::backend::ReformatResult, String> {
let body = Self::reformat_body(&req.rel_path, &req.scope, req.optimize_imports);
let resp = self.post("/reformat", &body)?;
if let Some(err) = resp.get("error") {
return Err(Self::error_from_envelope(err));
}
Ok(Self::parse_reformat_result(&resp))
}
fn rename(
&mut self,
_uri: &Uri,
_position: Position,
_new_name: &str,
) -> Result<Option<WorkspaceEdit>, String> {
Err("rename via JetBrains backend is not implemented yet (v2 edit spec)".to_string())
}
fn is_stale(&self, project_root: &str) -> bool {
match crate::lsp::port_discovery::read_port_file(project_root) {
Some(pf) => {
pf.pid != self.pid
|| pf.port != self.port
|| !crate::lsp::port_discovery::pid_alive(self.pid)
}
None => true,
}
}
fn last_truncation(&self) -> Option<crate::lsp::backend::Truncation> {
self.last_meta
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
fn mock_once(json_body: &'static str) -> u16 {
fn content_length(headers: &[u8]) -> usize {
let text = String::from_utf8_lossy(headers);
for line in text.lines() {
let lower = line.to_ascii_lowercase();
if let Some(v) = lower.strip_prefix("content-length:") {
return v.trim().parse().unwrap_or(0);
}
}
0
}
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10)));
let mut req: Vec<u8> = Vec::with_capacity(2048);
let mut buf = [0u8; 2048];
while let Ok(n) = stream.read(&mut buf) {
if n == 0 {
break;
}
req.extend_from_slice(&buf[..n]);
if let Some(pos) = req.windows(4).position(|w| w == b"\r\n\r\n") {
let body_have = req.len() - (pos + 4);
if body_have >= content_length(&req[..pos]) {
break; }
}
}
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
json_body.len(),
json_body
);
let _ = stream.write_all(resp.as_bytes());
let _ = stream.flush();
let _ = stream.shutdown(std::net::Shutdown::Write);
let _ = stream.read(&mut buf);
}
});
port
}
#[test]
fn references_parses_wire_locations() {
let body = r#"{"locations":[{"path":"src/main.rs","range":{"start":{"line":5,"character":13},"end":{"line":5,"character":18}}}]}"#;
let port = mock_once(body);
let mut backend = JetBrainsHttpBackend::new(
port,
"tok".to_string(),
"/proj".to_string(),
std::process::id(),
);
let uri = file_path_to_uri("/proj/src/main.rs").unwrap();
let locs = backend
.references(
&uri,
Position {
line: 5,
character: 13,
},
"project",
)
.expect("should parse");
assert_eq!(locs.len(), 1);
assert_eq!(locs[0].range.start.line, 5);
assert_eq!(locs[0].range.start.character, 13);
assert!(locs[0].uri.as_str().ends_with("/proj/src/main.rs"));
}
#[test]
fn type_hierarchy_parses_wire_tree() {
use crate::lsp::backend::HierarchyDirection;
let body = r#"{"tree":{"name":"Animal","path":"A.kt","line":1,"children":[{"name":"Dog","path":"A.kt","line":2,"children":[]}]},"truncated":false}"#;
let port = mock_once(body);
let mut backend = JetBrainsHttpBackend::new(
port,
"tok".to_string(),
"/proj".to_string(),
std::process::id(),
);
let uri = file_path_to_uri("/proj/A.kt").unwrap();
let tree = backend
.type_hierarchy(
&uri,
Position {
line: 0,
character: 0,
},
HierarchyDirection::Subtypes,
)
.expect("should parse");
assert_eq!(tree.name, "Animal");
assert_eq!(tree.line, 1);
assert_eq!(tree.children.len(), 1);
assert_eq!(tree.children[0].name, "Dog");
assert_eq!(tree.children[0].path, "A.kt");
}
#[test]
fn symbols_overview_parses_wire_items() {
let body = r#"{"symbols":[{"name":"Animal","kind":"interface","line":1},{"name":"main","kind":"function","line":9}],"truncated":false,"total":2}"#;
let port = mock_once(body);
let mut backend = JetBrainsHttpBackend::new(
port,
"tok".to_string(),
"/proj".to_string(),
std::process::id(),
);
let uri = file_path_to_uri("/proj/A.kt").unwrap();
let items = backend.symbols_overview(&uri).expect("should parse");
assert_eq!(items.len(), 2);
assert_eq!(items[0].kind, "interface");
assert_eq!(items[1].name, "main");
assert_eq!(items[1].line, 9);
}
#[test]
fn inspections_parses_wire_diags() {
let body = r#"{"diagnostics":[{"path":"A.kt","line":3,"severity":"WARNING","message":"unused variable"}],"truncated":false,"total":1}"#;
let port = mock_once(body);
let mut backend = JetBrainsHttpBackend::new(
port,
"tok".to_string(),
"/proj".to_string(),
std::process::id(),
);
let uri = file_path_to_uri("/proj/A.kt").unwrap();
let diags = backend.inspections(&uri).expect("should parse");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].path, "A.kt");
assert_eq!(diags[0].line, 3);
assert_eq!(diags[0].severity, "WARNING");
assert_eq!(diags[0].message, "unused variable");
}
#[test]
fn replace_symbol_body_parses_wire_result() {
let port = mock_once(
r#"{"applied":true,
"newRange":{"start":{"line":1,"character":0},"end":{"line":1,"character":3}},
"editedText":"NEW"}"#,
);
let mut be = JetBrainsHttpBackend::new(port, "tok".into(), "/tmp/proj".to_string(), 1234);
let edit = crate::lsp::backend::RangeEdit {
abs_path: "/tmp/proj/Foo.kt".into(),
rel_path: "Foo.kt".into(),
range: crate::lsp::backend::TextRange0Based {
start_line: 1,
start_char: 0,
end_line: 1,
end_char: 4,
},
text: "NEW".into(),
expected_hash: None,
};
let res = be.replace_symbol_body(&edit).unwrap();
assert!(res.applied);
assert_eq!(res.edited_text, "NEW");
assert_eq!(res.new_range.end_char, 3);
}
#[test]
fn edit_maps_error_envelope_to_err() {
let port = mock_once(r#"{"error":{"code":"CONFLICT","message":"stale"}}"#);
let mut be = JetBrainsHttpBackend::new(port, "tok".into(), "/tmp/proj".to_string(), 1234);
let edit = crate::lsp::backend::RangeEdit {
abs_path: "/tmp/proj/Foo.kt".into(),
rel_path: "Foo.kt".into(),
range: crate::lsp::backend::TextRange0Based {
start_line: 0,
start_char: 0,
end_line: 0,
end_char: 0,
},
text: "x".into(),
expected_hash: None,
};
assert_eq!(
be.replace_symbol_body(&edit).unwrap_err(),
"CONFLICT: stale"
);
}
#[test]
fn inspections_maps_error_envelope_to_err() {
let body = r#"{"error":{"code":"UNSUPPORTED_LANGUAGE","message":"only kotlin"}}"#;
let port = mock_once(body);
let mut backend = JetBrainsHttpBackend::new(
port,
"tok".to_string(),
"/proj".to_string(),
std::process::id(),
);
let uri = file_path_to_uri("/proj/A.kt").unwrap();
let err = backend.inspections(&uri).expect_err("envelope → Err");
assert_eq!(err, "UNSUPPORTED_LANGUAGE: only kotlin");
}
#[test]
fn list_inspections_parses_wire_items() {
let body = r#"{"inspections":[{"id":"UnusedSymbol","name":"Unused declaration","severity":"WARNING"}],"truncated":true,"total":342}"#;
let port = mock_once(body);
let mut backend = JetBrainsHttpBackend::new(
port,
"tok".to_string(),
"/proj".to_string(),
std::process::id(),
);
let items = backend.list_inspections().expect("should parse");
assert_eq!(items.len(), 1);
assert_eq!(items[0].id, "UnusedSymbol");
assert_eq!(items[0].name, "Unused declaration");
assert_eq!(items[0].severity, "WARNING");
let meta = backend.last_truncation().expect("meta recorded");
assert!(meta.truncated);
assert_eq!(meta.total, 342);
}
#[test]
fn references_records_truncation_meta() {
let body = r#"{"locations":[{"path":"a.rs","range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}}}],"truncated":true,"total":742}"#;
let port = mock_once(body);
let mut backend = JetBrainsHttpBackend::new(
port,
"tok".to_string(),
"/proj".to_string(),
std::process::id(),
);
let uri = file_path_to_uri("/proj/a.rs").unwrap();
let _ = backend
.references(
&uri,
Position {
line: 0,
character: 0,
},
"project",
)
.unwrap();
let meta = backend.last_truncation().expect("meta recorded");
assert!(meta.truncated);
assert_eq!(meta.total, 742);
}
#[test]
fn is_stale_true_when_no_port_file() {
let backend = JetBrainsHttpBackend::new(
12345,
"tok".to_string(),
"/nonexistent/leanctx/proj/xyz".to_string(),
999_999_999,
);
assert!(backend.is_stale("/nonexistent/leanctx/proj/xyz"));
}
#[test]
fn is_stale_false_for_matching_live_pid() {
let _lock = crate::core::data_dir::test_env_lock();
let tmp = std::env::temp_dir().join(format!("leanctx-stale-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let root = tmp.to_string_lossy().to_string();
std::env::set_var("LEAN_CTX_DATA_DIR", &tmp);
let pf_path = crate::lsp::port_discovery::port_file_path(&root).unwrap();
let pid = std::process::id();
std::fs::write(
&pf_path,
serde_json::json!({
"port": 4567,
"token": "tok",
"pid": pid,
"project_root": root,
"ide_version": "x",
})
.to_string(),
)
.unwrap();
let backend = JetBrainsHttpBackend::new(4567, "tok".to_string(), root.clone(), pid);
assert!(
!backend.is_stale(&root),
"matching live pid+port must be fresh"
);
let other = JetBrainsHttpBackend::new(4567, "tok".to_string(), root.clone(), pid + 1);
assert!(other.is_stale(&root), "pid mismatch must be stale");
std::env::remove_var("LEAN_CTX_DATA_DIR");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn canonical_root_strips_trailing_slash_and_resolves_realpath() {
let tmp = std::env::temp_dir();
let with_slash = format!("{}/", tmp.to_string_lossy());
let backend =
JetBrainsHttpBackend::new(1, "t".to_string(), with_slash.clone(), std::process::id());
let expected = std::fs::canonicalize(&tmp)
.unwrap()
.to_string_lossy()
.to_string();
assert_eq!(backend.project_root_for_test(), expected);
assert!(!backend.project_root_for_test().ends_with('/'));
}
#[test]
fn canonical_root_falls_back_to_raw_for_nonexistent() {
let raw = "/nonexistent/leanctx/xyz";
let backend =
JetBrainsHttpBackend::new(1, "t".to_string(), raw.to_string(), std::process::id());
assert_eq!(backend.project_root_for_test(), raw);
}
#[test]
fn rename_preview_parses_usages_and_conflicts() {
let body = r#"{"usages":[
{"path":"src/a.rs","range":{"start":{"line":5,"character":4},"end":{"line":5,"character":7}},"context":"foo()"},
{"path":"src/b.rs","range":{"start":{"line":1,"character":0},"end":{"line":1,"character":3}}}
],"conflicts":[
{"path":"src/a.rs","range":{"start":{"line":9,"character":0},"end":{"line":9,"character":3}},"message":"name clash"}
]}"#;
let port = mock_once(body);
let mut be = JetBrainsHttpBackend::new(port, "tok".into(), "/proj".to_string(), 1234);
let q = crate::lsp::backend::RenameQuery {
abs_path: "/proj/src/a.rs".into(),
rel_path: "src/a.rs".into(),
target_range: crate::lsp::backend::TextRange0Based {
start_line: 5,
start_char: 4,
end_line: 5,
end_char: 7,
},
new_name: "bar".into(),
search_comments: false,
search_text_occurrences: false,
};
let plan = be.rename_preview(&q).unwrap();
assert_eq!(plan.usages.len(), 2);
assert_eq!(plan.usages[0].path, "src/a.rs");
assert_eq!(plan.usages[0].context.as_deref(), Some("foo()"));
assert_eq!(plan.usages[1].context, None);
assert_eq!(plan.conflicts.len(), 1);
assert_eq!(plan.conflicts[0].message, "name clash");
}
#[test]
fn rename_preview_maps_error_envelope() {
let port = mock_once(r#"{"error":{"code":"INDEXING","message":"busy"}}"#);
let mut be = JetBrainsHttpBackend::new(port, "tok".into(), "/proj".to_string(), 1234);
let q = crate::lsp::backend::RenameQuery {
abs_path: "/proj/a.rs".into(),
rel_path: "a.rs".into(),
target_range: crate::lsp::backend::TextRange0Based {
start_line: 0,
start_char: 0,
end_line: 0,
end_char: 1,
},
new_name: "y".into(),
search_comments: false,
search_text_occurrences: false,
};
assert_eq!(be.rename_preview(&q).unwrap_err(), "INDEXING: busy");
}
#[test]
fn rename_apply_parses_changed_paths() {
let body = r#"{"applied":true,"changed_paths":["src/a.rs","src/b.rs"]}"#;
let port = mock_once(body);
let mut be = JetBrainsHttpBackend::new(port, "tok".into(), "/proj".to_string(), 1234);
let a = crate::lsp::backend::RenameApply {
abs_path: "/proj/src/a.rs".into(),
rel_path: "src/a.rs".into(),
target_range: crate::lsp::backend::TextRange0Based {
start_line: 5,
start_char: 4,
end_line: 5,
end_char: 7,
},
new_name: "bar".into(),
force: false,
};
let res = be.rename_apply(&a).unwrap();
assert!(res.applied);
assert_eq!(res.changed_paths, vec!["src/a.rs", "src/b.rs"]);
}
#[test]
fn move_body_path_and_parent_variants() {
use crate::lsp::backend::{MoveTarget, TextRange0Based};
let r = TextRange0Based {
start_line: 2,
start_char: 0,
end_line: 2,
end_char: 12,
};
let path_body = JetBrainsHttpBackend::move_body(
"Widget.kt",
r,
&MoveTarget::Path {
abs_path: "/p/app/moved".into(),
rel_path: "app/moved".into(),
},
);
assert_eq!(path_body["path"], "Widget.kt");
assert_eq!(path_body["target"]["kind"], "path");
assert_eq!(path_body["target"]["path"], "app/moved");
assert!(path_body["target"].get("range").is_none());
let pr = TextRange0Based {
start_line: 0,
start_char: 0,
end_line: 5,
end_char: 1,
};
let parent_body = JetBrainsHttpBackend::move_body(
"Widget.kt",
r,
&MoveTarget::Parent {
abs_path: "/p/Other.kt".into(),
rel_path: "Other.kt".into(),
range: pr,
},
);
assert_eq!(parent_body["target"]["kind"], "parent");
assert_eq!(parent_body["target"]["path"], "Other.kt");
assert_eq!(parent_body["target"]["range"]["start"]["line"], 0);
assert_eq!(parent_body["target"]["range"]["end"]["line"], 5);
}
#[test]
fn safe_delete_body_carries_flags() {
use crate::lsp::backend::TextRange0Based;
let r = TextRange0Based {
start_line: 2,
start_char: 0,
end_line: 2,
end_char: 12,
};
let body = JetBrainsHttpBackend::safe_delete_body("Widget.kt", r, true, false);
assert_eq!(body["path"], "Widget.kt");
assert_eq!(body["range"]["start"]["line"], 2);
assert_eq!(body["force"], true);
assert_eq!(body["propagate"], false);
}
#[test]
fn inline_body_carries_keep_definition() {
let r = crate::lsp::backend::TextRange0Based {
start_line: 2,
start_char: 4,
end_line: 2,
end_char: 7,
};
let body = JetBrainsHttpBackend::inline_body("Calc.kt", r, true);
assert_eq!(body["path"], "Calc.kt");
assert_eq!(body["keep_definition"], true);
assert_eq!(body["range"]["start"]["line"], 2);
}
#[test]
fn reformat_body_encodes_scope_variants() {
use crate::lsp::backend::{ReformatScope, TextRange0Based};
let file = JetBrainsHttpBackend::reformat_body("M.kt", &ReformatScope::File, true);
assert_eq!(file["scope"]["kind"], "file");
assert_eq!(file["optimize_imports"], true);
let region = JetBrainsHttpBackend::reformat_body(
"M.kt",
&ReformatScope::Region {
range: TextRange0Based {
start_line: 9,
start_char: 0,
end_line: 19,
end_char: 0,
},
},
false,
);
assert_eq!(region["scope"]["kind"], "region");
assert_eq!(region["scope"]["range"]["start"]["line"], 9);
let sym = JetBrainsHttpBackend::reformat_body(
"M.kt",
&ReformatScope::Symbol {
range: TextRange0Based {
start_line: 3,
start_char: 0,
end_line: 5,
end_char: 1,
},
},
false,
);
assert_eq!(sym["scope"]["kind"], "symbol");
}
#[test]
fn parse_reformat_result_reads_changed_paths() {
let v = serde_json::json!({ "applied": true, "changed_paths": ["M.kt"] });
let r = JetBrainsHttpBackend::parse_reformat_result(&v);
assert!(r.applied);
assert_eq!(r.changed_paths, vec!["M.kt".to_string()]);
}
}