use std::path::PathBuf;
use ratatui::style::{Color, Modifier, Style};
use serde_json::json;
use crate::completion::{Completion, item_from_lsp};
use super::{App, DiagSeverity, LspDiag, LspPendingRequest, LspServerInfo};
fn absolutize(p: &std::path::Path) -> PathBuf {
if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir()
.ok()
.map(|cwd| cwd.join(p))
.unwrap_or_else(|| p.to_path_buf())
}
}
pub(super) fn language_id_for_ext(ext: &str) -> Option<&'static str> {
match ext {
"rs" => Some("rust"),
"ts" | "tsx" => Some("typescript"),
"js" | "jsx" => Some("javascript"),
"py" => Some("python"),
"go" => Some("go"),
"c" | "h" => Some("c"),
"cpp" | "cc" | "cxx" | "hpp" => Some("cpp"),
"lua" => Some("lua"),
"toml" => Some("toml"),
"json" => Some("json"),
"md" => Some("markdown"),
_ => None,
}
}
fn convert_severity(s: Option<lsp_types::DiagnosticSeverity>) -> DiagSeverity {
match s {
Some(lsp_types::DiagnosticSeverity::ERROR) => DiagSeverity::Error,
Some(lsp_types::DiagnosticSeverity::WARNING) => DiagSeverity::Warning,
Some(lsp_types::DiagnosticSeverity::INFORMATION) => DiagSeverity::Info,
Some(lsp_types::DiagnosticSeverity::HINT) => DiagSeverity::Hint,
_ => DiagSeverity::Error, }
}
fn severity_sign(sev: DiagSeverity) -> (char, Style) {
match sev {
DiagSeverity::Error => (
'E',
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
DiagSeverity::Warning => (
'W',
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
DiagSeverity::Info => ('I', Style::default().fg(Color::Blue)),
DiagSeverity::Hint => ('H', Style::default().fg(Color::Cyan)),
}
}
fn severity_priority(sev: DiagSeverity) -> u8 {
match sev {
DiagSeverity::Error => 100,
DiagSeverity::Warning => 80,
DiagSeverity::Info => 60,
DiagSeverity::Hint => 40,
}
}
impl App {
pub fn drain_lsp_events(&mut self) {
let events: Vec<hjkl_lsp::LspEvent> = if let Some(ref mgr) = self.lsp {
let mut v = Vec::new();
while let Some(evt) = mgr.try_recv_event() {
v.push(evt);
}
v
} else {
return;
};
for evt in events {
match evt {
hjkl_lsp::LspEvent::ServerInitialized { key, capabilities } => {
tracing::info!(?key, "lsp server initialized");
self.lsp_state.insert(
key,
LspServerInfo {
initialized: true,
capabilities,
},
);
}
hjkl_lsp::LspEvent::ServerExited { key, status } => {
tracing::warn!(?key, ?status, "lsp server exited");
self.lsp_state.remove(&key);
}
hjkl_lsp::LspEvent::Notification {
key,
method,
params,
} => {
tracing::debug!(?key, method, "lsp notification");
if method == "textDocument/publishDiagnostics" {
self.handle_publish_diagnostics(params);
}
}
hjkl_lsp::LspEvent::Response { request_id, result } => {
tracing::debug!(request_id, "lsp response");
if let Some(pending) = self.lsp_pending.remove(&request_id) {
self.handle_lsp_response(pending, result);
}
}
}
}
}
pub(crate) fn handle_publish_diagnostics(&mut self, params: serde_json::Value) {
let parsed: lsp_types::PublishDiagnosticsParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => {
tracing::warn!("publishDiagnostics: failed to parse: {e}");
return;
}
};
let uri_url: url::Url = match url::Url::parse(parsed.uri.as_str()) {
Ok(u) => u,
Err(e) => {
tracing::warn!("publishDiagnostics: bad URI: {e}");
return;
}
};
let uri_path = match hjkl_lsp::uri::to_path(&uri_url) {
Some(p) => p,
None => {
tracing::debug!("publishDiagnostics: non-file URI, skipping");
return;
}
};
let slot_idx = self.slots.iter().position(|s| {
s.filename
.as_ref()
.map(|p| {
let abs = if p.is_absolute() {
p.clone()
} else {
std::env::current_dir().unwrap_or_default().join(p)
};
abs == uri_path
})
.unwrap_or(false)
});
let slot_idx = match slot_idx {
Some(i) => i,
None => {
tracing::debug!("publishDiagnostics: no matching slot for {:?}", uri_path);
return;
}
};
let mut lsp_diags: Vec<LspDiag> = Vec::new();
let mut sign_map: std::collections::HashMap<usize, (DiagSeverity, char, Style, u8)> =
std::collections::HashMap::new();
for d in &parsed.diagnostics {
let start_row = d.range.start.line as usize;
let start_col = d.range.start.character as usize;
let end_row = d.range.end.line as usize;
let end_col = d.range.end.character as usize;
let severity = convert_severity(d.severity);
let code = d.code.as_ref().map(|c| match c {
lsp_types::NumberOrString::Number(n) => n.to_string(),
lsp_types::NumberOrString::String(s) => s.clone(),
});
let source = d.source.clone();
lsp_diags.push(LspDiag {
start_row,
start_col,
end_row,
end_col,
severity,
message: d.message.clone(),
source,
code,
});
let prio = severity_priority(severity);
let entry = sign_map
.entry(start_row)
.or_insert((severity, 'E', Style::default(), 0));
if prio > entry.3 {
let (ch, style) = severity_sign(severity);
*entry = (severity, ch, style, prio);
}
}
let diag_signs_lsp: Vec<hjkl_buffer::Sign> = sign_map
.into_iter()
.map(|(row, (_, ch, style, priority))| hjkl_buffer::Sign {
row,
ch,
style,
priority,
})
.collect();
let slot = &mut self.slots[slot_idx];
slot.lsp_diags = lsp_diags;
slot.diag_signs_lsp = diag_signs_lsp;
}
pub(crate) fn lsp_notify_change_active(&mut self) {
let mgr = match self.lsp.as_ref() {
Some(m) => m,
None => return,
};
let slot_idx = self.focused_slot_idx();
let slot = &mut self.slots[slot_idx];
let dg = slot.editor.buffer().dirty_gen();
if slot.last_lsp_dirty_gen == Some(dg) {
return;
}
slot.last_lsp_dirty_gen = Some(dg);
let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;
let text = slot.editor.buffer().lines().to_vec().join("\n");
mgr.notify_change(buffer_id, &text);
}
pub(crate) fn active_filetype_label(&self) -> String {
let ext = self
.active()
.filename
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.unwrap_or("");
if ext.is_empty() {
return "(none)".to_string();
}
match language_id_for_ext(ext) {
Some(lang) => lang.to_string(),
None => ext.to_string(),
}
}
pub(crate) fn active_lsp_server_name(&self) -> Option<String> {
self.lsp.as_ref()?;
let lang = self
.active()
.filename
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.and_then(language_id_for_ext)?;
self.lsp_state
.keys()
.find(|k| k.language == lang)
.map(|k| k.language.clone())
}
pub(crate) fn restart_lsp(&mut self) {
let slot_idx = self.focused_slot_idx();
self.lsp_detach_buffer(slot_idx);
self.lsp_attach_buffer(slot_idx);
self.status_message = Some("LSP restarted".into());
}
pub(crate) fn active_has_lsp(&self) -> bool {
if self.lsp.is_none() {
return false;
}
let lang = self
.active()
.filename
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.and_then(language_id_for_ext);
let Some(lang) = lang else {
return false;
};
self.lsp_state.keys().any(|k| k.language == lang)
}
pub(crate) fn lsp_attach_buffer(&mut self, slot_idx: usize) {
let mgr = match self.lsp.as_ref() {
Some(m) => m,
None => return,
};
let slot = &self.slots[slot_idx];
let path = match slot.filename.as_ref() {
Some(p) => absolutize(p),
None => return,
};
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let language_id = match language_id_for_ext(ext) {
Some(id) => id,
None => return,
};
if !self.config.lsp.servers.contains_key(language_id) {
return;
}
let text = self.slots[slot_idx]
.editor
.buffer()
.lines()
.to_vec()
.join("\n");
let buffer_id = self.slots[slot_idx].buffer_id as hjkl_lsp::BufferId;
mgr.attach_buffer(buffer_id, &path, language_id, &text);
}
pub(crate) fn lsp_detach_buffer(&mut self, slot_idx: usize) {
let mgr = match self.lsp.as_ref() {
Some(m) => m,
None => return,
};
let buffer_id = self.slots[slot_idx].buffer_id as hjkl_lsp::BufferId;
mgr.detach_buffer(buffer_id);
}
pub(crate) fn lsp_alloc_request_id(&mut self) -> i64 {
let id = self.lsp_next_request_id;
self.lsp_next_request_id += 1;
id
}
fn lsp_position_params(
&self,
) -> Option<(serde_json::Value, hjkl_lsp::BufferId, (usize, usize))> {
let slot = self.active();
let path = absolutize(slot.filename.as_ref()?);
let uri = hjkl_lsp::uri::from_path(&path).ok()?;
let cursor = slot.editor.buffer().cursor();
let row = cursor.row;
let col = cursor.col;
let params = json!({
"textDocument": { "uri": uri.as_str() },
"position": { "line": row as u32, "character": col as u32 },
});
let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;
Some((params, buffer_id, (row, col)))
}
fn lsp_send_goto(
&mut self,
method: &str,
extras: Option<serde_json::Value>,
make_pending: impl FnOnce(hjkl_lsp::BufferId, (usize, usize)) -> LspPendingRequest,
) {
if self.lsp.is_none() {
self.status_message =
Some("LSP: not enabled (set [lsp] enabled = true in config)".into());
return;
}
let (mut params, buffer_id, origin) = match self.lsp_position_params() {
Some(v) => v,
None => {
self.status_message = Some(
"LSP: no file open in this buffer (use :e <file> or open from the picker)"
.into(),
);
return;
}
};
if let (Some(extra), Some(obj)) = (extras, params.as_object_mut())
&& let Some(extra_obj) = extra.as_object()
{
for (k, v) in extra_obj {
obj.insert(k.clone(), v.clone());
}
}
let request_id = self.lsp_alloc_request_id();
let pending = make_pending(buffer_id, origin);
self.lsp_pending.insert(request_id, pending);
if let Some(mgr) = self.lsp.as_ref() {
mgr.send_request(request_id, buffer_id, method, params);
}
}
pub(crate) fn lsp_goto_definition(&mut self) {
self.lsp_send_goto("textDocument/definition", None, |buf, orig| {
LspPendingRequest::GotoDefinition {
buffer_id: buf,
origin: orig,
}
});
}
pub(crate) fn lsp_goto_declaration(&mut self) {
self.lsp_send_goto("textDocument/declaration", None, |buf, orig| {
LspPendingRequest::GotoDeclaration {
buffer_id: buf,
origin: orig,
}
});
}
pub(crate) fn lsp_goto_type_definition(&mut self) {
self.lsp_send_goto("textDocument/typeDefinition", None, |buf, orig| {
LspPendingRequest::GotoTypeDefinition {
buffer_id: buf,
origin: orig,
}
});
}
pub(crate) fn lsp_goto_implementation(&mut self) {
self.lsp_send_goto("textDocument/implementation", None, |buf, orig| {
LspPendingRequest::GotoImplementation {
buffer_id: buf,
origin: orig,
}
});
}
pub(crate) fn lsp_goto_references(&mut self) {
self.lsp_send_goto(
"textDocument/references",
Some(json!({ "context": { "includeDeclaration": true } })),
|buf, orig| LspPendingRequest::GotoReferences {
buffer_id: buf,
origin: orig,
},
);
}
pub(crate) fn lsp_hover(&mut self) {
self.lsp_send_goto("textDocument/hover", None, |buf, orig| {
LspPendingRequest::Hover {
buffer_id: buf,
origin: orig,
}
});
}
pub(crate) fn lsp_hover_at_doc(&mut self, doc_row: usize, doc_col: usize) {
if self.lsp.is_none() {
return; }
let slot = self.active();
let path = match slot.filename.as_ref() {
Some(p) => absolutize(p),
None => return,
};
let uri = match hjkl_lsp::uri::from_path(&path) {
Ok(u) => u,
Err(_) => return,
};
let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;
let params = serde_json::json!({
"textDocument": { "uri": uri.as_str() },
"position": { "line": doc_row as u32, "character": doc_col as u32 },
});
let request_id = self.lsp_alloc_request_id();
let pending = LspPendingRequest::HoverAtMouse {
buffer_id,
origin: (doc_row, doc_col),
};
self.lsp_pending.insert(request_id, pending);
if let Some(mgr) = self.lsp.as_ref() {
mgr.send_request(request_id, buffer_id, "textDocument/hover", params);
}
}
pub(crate) fn handle_lsp_response(
&mut self,
pending: LspPendingRequest,
result: Result<serde_json::Value, hjkl_lsp::RpcError>,
) {
match pending {
LspPendingRequest::GotoDefinition { buffer_id, origin } => {
self.handle_goto_response(buffer_id, origin, result, "definition");
}
LspPendingRequest::GotoDeclaration { buffer_id, origin } => {
self.handle_goto_response(buffer_id, origin, result, "declaration");
}
LspPendingRequest::GotoTypeDefinition { buffer_id, origin } => {
self.handle_goto_response(buffer_id, origin, result, "type definition");
}
LspPendingRequest::GotoImplementation { buffer_id, origin } => {
self.handle_goto_response(buffer_id, origin, result, "implementation");
}
LspPendingRequest::GotoReferences { buffer_id, origin } => {
self.handle_references_response(buffer_id, origin, result);
}
LspPendingRequest::Hover { buffer_id, origin } => {
self.handle_hover_response(buffer_id, origin, result);
}
LspPendingRequest::HoverAtMouse { buffer_id, origin } => {
self.handle_hover_at_mouse_response(buffer_id, origin, result);
}
LspPendingRequest::Completion {
buffer_id,
anchor_row,
anchor_col,
} => {
self.handle_completion_response(buffer_id, anchor_row, anchor_col, result);
}
LspPendingRequest::CodeAction {
buffer_id,
anchor_row,
anchor_col,
} => {
self.handle_code_action_response(buffer_id, anchor_row, anchor_col, result);
}
LspPendingRequest::Rename {
buffer_id,
anchor_row,
anchor_col,
new_name,
} => {
self.handle_rename_response(buffer_id, anchor_row, anchor_col, new_name, result);
}
LspPendingRequest::Format { buffer_id, range } => {
self.handle_format_response(buffer_id, range, result);
}
}
}
fn parse_goto_locations(result: serde_json::Value) -> Vec<lsp_types::Location> {
if result.is_null() {
return Vec::new();
}
if let Ok(resp) =
serde_json::from_value::<lsp_types::GotoDefinitionResponse>(result.clone())
{
return match resp {
lsp_types::GotoDefinitionResponse::Scalar(loc) => vec![loc],
lsp_types::GotoDefinitionResponse::Array(locs) => locs,
lsp_types::GotoDefinitionResponse::Link(links) => links
.into_iter()
.map(|l| lsp_types::Location {
uri: l.target_uri,
range: l.target_selection_range,
})
.collect(),
};
}
if let Ok(locs) = serde_json::from_value::<Vec<lsp_types::Location>>(result.clone()) {
return locs;
}
if let Ok(loc) = serde_json::from_value::<lsp_types::Location>(result) {
return vec![loc];
}
Vec::new()
}
fn jump_to_location(&mut self, loc: &lsp_types::Location) {
let target_path: Option<PathBuf> = {
let url: url::Url = match url::Url::parse(loc.uri.as_str()) {
Ok(u) => u,
Err(_) => return,
};
hjkl_lsp::uri::to_path(&url)
};
let row = loc.range.start.line as usize;
let col = loc.range.start.character as usize;
let slot_idx = if let Some(ref tp) = target_path {
self.slots.iter().position(|s| {
s.filename
.as_ref()
.map(|p| {
let abs_p = if p.is_absolute() {
p.clone()
} else {
std::env::current_dir().unwrap_or_default().join(p)
};
&abs_p == tp
})
.unwrap_or(false)
})
} else {
None
};
if let Some(idx) = slot_idx {
if idx != self.focused_slot_idx() {
self.switch_to(idx);
}
} else if let Some(ref tp) = target_path {
match self.open_new_slot(tp.clone()) {
Ok(idx) => {
self.switch_to(idx);
}
Err(e) => {
self.status_message = Some(format!("LSP goto: {e}"));
return;
}
}
} else {
self.status_message = Some("LSP goto: non-file URI".into());
return;
}
self.active_mut().editor.jump_cursor(row, col);
self.active_mut().editor.ensure_cursor_in_scrolloff();
self.sync_viewport_from_editor();
}
pub(crate) fn handle_goto_response(
&mut self,
_buffer_id: hjkl_lsp::BufferId,
_origin: (usize, usize),
result: Result<serde_json::Value, hjkl_lsp::RpcError>,
kind_label: &str,
) {
let val = match result {
Ok(v) => v,
Err(e) => {
self.status_message = Some(format!("LSP {kind_label}: {}", e.message));
return;
}
};
let locs = Self::parse_goto_locations(val);
if locs.is_empty() {
self.status_message = Some(format!("no {kind_label} found"));
return;
}
if locs.len() == 1 {
self.jump_to_location(&locs[0]);
} else {
self.open_lsp_locations_picker(&locs, kind_label);
}
}
pub(crate) fn handle_references_response(
&mut self,
_buffer_id: hjkl_lsp::BufferId,
_origin: (usize, usize),
result: Result<serde_json::Value, hjkl_lsp::RpcError>,
) {
let val = match result {
Ok(v) => v,
Err(e) => {
self.status_message = Some(format!("LSP references: {}", e.message));
return;
}
};
let locs = Self::parse_goto_locations(val);
if locs.is_empty() {
self.status_message = Some("no references found".into());
return;
}
self.open_lsp_locations_picker(&locs, "references");
}
fn open_lsp_locations_picker(&mut self, locs: &[lsp_types::Location], kind_label: &str) {
use crate::picker_action::AppAction;
let cwd = std::env::current_dir().ok();
let entries: Vec<(String, AppAction)> = locs
.iter()
.filter_map(|loc| {
let url: url::Url = url::Url::parse(loc.uri.as_str()).ok()?;
let path = hjkl_lsp::uri::to_path(&url)?;
let row = loc.range.start.line;
let col = loc.range.start.character as usize;
let display_path = cwd
.as_ref()
.and_then(|c| path.strip_prefix(c).ok())
.map(|p| p.display().to_string())
.unwrap_or_else(|| path.display().to_string());
let label = format!("{display_path}:{}: col {}", row + 1, col + 1);
Some((label, AppAction::OpenPathAtLine(path, row + 1)))
})
.collect();
if entries.is_empty() {
self.status_message = Some(format!("no {kind_label} found"));
return;
}
let source = Box::new(crate::picker_sources::StaticListSource::new(
kind_label.to_string(),
entries,
));
self.picker = Some(crate::picker::Picker::new(source));
}
pub(crate) fn maybe_auto_trigger_completion(&mut self, ch: char) {
let triggers: Vec<String> = self
.active()
.filename
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.and_then(|ext| language_id_for_ext(ext))
.and_then(|lang| {
self.lsp_state.iter().find_map(|(key, info)| {
if key.language == lang {
info.capabilities
.pointer("/completionProvider/triggerCharacters")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|s| s.as_str().map(|s| s.to_string()))
.collect()
})
} else {
None
}
})
})
.unwrap_or_default();
let ch_str = ch.to_string();
if triggers.contains(&ch_str) {
self.lsp_request_completion();
}
}
pub(crate) fn lsp_request_completion(&mut self) {
if self.lsp.is_none() {
self.status_message =
Some("LSP: not enabled (set [lsp] enabled = true in config)".into());
return;
}
let (params, buffer_id, (row, col)) = match self.lsp_position_params() {
Some(v) => v,
None => {
self.status_message = Some(
"LSP: no file open in this buffer (use :e <file> or open from the picker)"
.into(),
);
return;
}
};
let request_id = self.lsp_alloc_request_id();
self.lsp_pending.insert(
request_id,
LspPendingRequest::Completion {
buffer_id,
anchor_row: row,
anchor_col: col,
},
);
if let Some(mgr) = self.lsp.as_ref() {
mgr.send_request(request_id, buffer_id, "textDocument/completion", params);
}
}
pub(crate) fn lsp_code_actions(&mut self) {
if self.lsp.is_none() {
self.status_message =
Some("LSP: not enabled (set [lsp] enabled = true in config)".into());
return;
}
let slot = self.active();
let path = match slot.filename.as_ref() {
Some(p) => absolutize(p),
None => {
self.status_message = Some(
"LSP: no file open in this buffer (use :e <file> or open from the picker)"
.into(),
);
return;
}
};
let uri = match hjkl_lsp::uri::from_path(&path).ok() {
Some(u) => u,
None => {
self.status_message = Some("LSP: cannot build URI".into());
return;
}
};
let cursor = slot.editor.buffer().cursor();
let row = cursor.row as u32;
let col = cursor.col as u32;
let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;
let overlapping_diags: Vec<lsp_types::Diagnostic> = slot
.lsp_diags
.iter()
.filter(|d| {
let after_start = (cursor.row, cursor.col) >= (d.start_row, d.start_col);
let before_end = cursor.row < d.end_row
|| (cursor.row == d.end_row && cursor.col < d.end_col)
|| (cursor.row == d.start_row && d.start_row == d.end_row);
after_start && (before_end || cursor.row == d.start_row)
})
.map(|d| {
let severity = match d.severity {
super::DiagSeverity::Error => Some(lsp_types::DiagnosticSeverity::ERROR),
super::DiagSeverity::Warning => Some(lsp_types::DiagnosticSeverity::WARNING),
super::DiagSeverity::Info => Some(lsp_types::DiagnosticSeverity::INFORMATION),
super::DiagSeverity::Hint => Some(lsp_types::DiagnosticSeverity::HINT),
};
let code = d.code.as_ref().map(|c| {
if let Ok(n) = c.parse::<i32>() {
lsp_types::NumberOrString::Number(n)
} else {
lsp_types::NumberOrString::String(c.clone())
}
});
lsp_types::Diagnostic {
range: lsp_types::Range {
start: lsp_types::Position {
line: d.start_row as u32,
character: d.start_col as u32,
},
end: lsp_types::Position {
line: d.end_row as u32,
character: d.end_col as u32,
},
},
severity,
code,
source: d.source.clone(),
message: d.message.clone(),
..Default::default()
}
})
.collect();
let params = json!({
"textDocument": { "uri": uri.as_str() },
"range": {
"start": { "line": row, "character": col },
"end": { "line": row, "character": col },
},
"context": {
"diagnostics": overlapping_diags,
"triggerKind": 1, },
});
let request_id = self.lsp_alloc_request_id();
self.lsp_pending.insert(
request_id,
LspPendingRequest::CodeAction {
buffer_id,
anchor_row: cursor.row,
anchor_col: cursor.col,
},
);
if let Some(mgr) = self.lsp.as_ref() {
mgr.send_request(request_id, buffer_id, "textDocument/codeAction", params);
}
}
pub(crate) fn handle_code_action_response(
&mut self,
_buffer_id: hjkl_lsp::BufferId,
_anchor_row: usize,
_anchor_col: usize,
result: Result<serde_json::Value, hjkl_lsp::RpcError>,
) {
let val = match result {
Ok(v) => v,
Err(e) => {
self.status_message = Some(format!("LSP codeAction: {}", e.message));
return;
}
};
if val.is_null() {
self.status_message = Some("no code actions".into());
return;
}
let actions: Vec<lsp_types::CodeActionOrCommand> = match serde_json::from_value(val) {
Ok(a) => a,
Err(_) => {
self.status_message = Some("LSP codeAction: could not parse response".into());
return;
}
};
if actions.is_empty() {
self.status_message = Some("no code actions".into());
return;
}
if actions.len() == 1 {
let action = actions.into_iter().next().unwrap();
self.apply_code_action_or_command(action);
return;
}
use crate::picker_action::AppAction;
let entries: Vec<(String, AppAction)> = actions
.iter()
.enumerate()
.map(|(i, action)| {
let label = match action {
lsp_types::CodeActionOrCommand::CodeAction(ca) => ca.title.clone(),
lsp_types::CodeActionOrCommand::Command(cmd) => cmd.title.clone(),
};
(label, AppAction::ApplyCodeAction(i))
})
.collect();
self.pending_code_actions = actions;
let source = Box::new(crate::picker_sources::StaticListSource::new(
"code actions".to_string(),
entries,
));
self.picker = Some(crate::picker::Picker::new(source));
}
pub(crate) fn apply_code_action_or_command(&mut self, item: lsp_types::CodeActionOrCommand) {
match item {
lsp_types::CodeActionOrCommand::CodeAction(ca) => {
if let Some(edit) = ca.edit {
match self.apply_workspace_edit(edit) {
Ok(count) => {
self.status_message = Some(format!("{count} files changed"));
}
Err(e) => {
self.status_message = Some(format!("LSP codeAction: {e}"));
return;
}
}
}
if let Some(cmd) = ca.command {
self.lsp_execute_command(&cmd.command, cmd.arguments.unwrap_or_default());
}
}
lsp_types::CodeActionOrCommand::Command(cmd) => {
self.lsp_execute_command(&cmd.command, cmd.arguments.unwrap_or_default());
}
}
}
fn lsp_execute_command(&mut self, command: &str, args: Vec<serde_json::Value>) {
let buffer_id = self.active().buffer_id as hjkl_lsp::BufferId;
let request_id = self.lsp_alloc_request_id();
let params = json!({
"command": command,
"arguments": args,
});
if let Some(mgr) = self.lsp.as_ref() {
mgr.send_request(request_id, buffer_id, "workspace/executeCommand", params);
}
}
pub(crate) fn apply_workspace_edit(
&mut self,
edit: lsp_types::WorkspaceEdit,
) -> Result<usize, String> {
let mut file_edits: Vec<(url::Url, Vec<lsp_types::TextEdit>)> = Vec::new();
if let Some(doc_changes) = edit.document_changes {
match doc_changes {
lsp_types::DocumentChanges::Edits(edits) => {
for tde in edits {
let url: url::Url = url::Url::parse(tde.text_document.uri.as_str())
.map_err(|e| format!("bad URI: {e}"))?;
let text_edits: Vec<lsp_types::TextEdit> = tde
.edits
.into_iter()
.filter_map(|e| match e {
lsp_types::OneOf::Left(te) => Some(te),
lsp_types::OneOf::Right(_) => None, })
.collect();
file_edits.push((url, text_edits));
}
}
lsp_types::DocumentChanges::Operations(ops) => {
for op in ops {
match op {
lsp_types::DocumentChangeOperation::Edit(tde) => {
let url: url::Url = url::Url::parse(tde.text_document.uri.as_str())
.map_err(|e| format!("bad URI: {e}"))?;
let text_edits: Vec<lsp_types::TextEdit> = tde
.edits
.into_iter()
.filter_map(|e| match e {
lsp_types::OneOf::Left(te) => Some(te),
lsp_types::OneOf::Right(_) => None,
})
.collect();
file_edits.push((url, text_edits));
}
lsp_types::DocumentChangeOperation::Op(_) => {
}
}
}
}
}
} else if let Some(changes) = edit.changes {
for (uri, edits) in changes {
let url: url::Url =
url::Url::parse(uri.as_str()).map_err(|e| format!("bad URI: {e}"))?;
file_edits.push((url, edits));
}
}
let count = file_edits.len();
for (url, mut edits) in file_edits {
let target_path = hjkl_lsp::uri::to_path(&url);
let slot_idx = if let Some(ref tp) = target_path {
let existing = self.slots.iter().position(|s| {
s.filename
.as_ref()
.map(|p| {
let abs = if p.is_absolute() {
p.clone()
} else {
std::env::current_dir().unwrap_or_default().join(p)
};
&abs == tp
})
.unwrap_or(false)
});
match existing {
Some(idx) => idx,
None => self.open_new_slot(tp.clone())?,
}
} else {
return Err(format!("non-file URI: {url}"));
};
edits.sort_by(|a, b| {
let ea = (a.range.end.line, a.range.end.character);
let eb = (b.range.end.line, b.range.end.character);
eb.cmp(&ea)
});
use hjkl_engine::{BufferEdit, Pos};
for te in edits {
let start = Pos {
line: te.range.start.line,
col: te.range.start.character,
};
let end = Pos {
line: te.range.end.line,
col: te.range.end.character,
};
BufferEdit::replace_range(
self.slots[slot_idx].editor.buffer_mut(),
start..end,
&te.new_text,
);
}
let _ = self.slots[slot_idx].editor.take_dirty();
self.slots[slot_idx].dirty = true;
}
Ok(count)
}
pub(crate) fn lsp_rename(&mut self, new_name: String) {
if self.lsp.is_none() {
self.status_message =
Some("LSP: not enabled (set [lsp] enabled = true in config)".into());
return;
}
let slot = self.active();
let path = match slot.filename.as_ref() {
Some(p) => absolutize(p),
None => {
self.status_message = Some(
"LSP: no file open in this buffer (use :e <file> or open from the picker)"
.into(),
);
return;
}
};
let uri = match hjkl_lsp::uri::from_path(&path).ok() {
Some(u) => u,
None => {
self.status_message = Some("LSP: cannot build URI".into());
return;
}
};
let cursor = slot.editor.buffer().cursor();
let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;
let params = json!({
"textDocument": { "uri": uri.as_str() },
"position": { "line": cursor.row as u32, "character": cursor.col as u32 },
"newName": new_name,
});
let request_id = self.lsp_alloc_request_id();
self.lsp_pending.insert(
request_id,
LspPendingRequest::Rename {
buffer_id,
anchor_row: cursor.row,
anchor_col: cursor.col,
new_name,
},
);
if let Some(mgr) = self.lsp.as_ref() {
mgr.send_request(request_id, buffer_id, "textDocument/rename", params);
}
}
pub(crate) fn handle_rename_response(
&mut self,
_buffer_id: hjkl_lsp::BufferId,
_anchor_row: usize,
_anchor_col: usize,
_new_name: String,
result: Result<serde_json::Value, hjkl_lsp::RpcError>,
) {
let val = match result {
Ok(v) => v,
Err(e) => {
self.status_message = Some(format!("LSP rename: {}", e.message));
return;
}
};
if val.is_null() {
self.status_message = Some("E: cannot rename here".into());
return;
}
let workspace_edit: lsp_types::WorkspaceEdit = match serde_json::from_value(val) {
Ok(we) => we,
Err(_) => {
self.status_message = Some("LSP rename: could not parse response".into());
return;
}
};
match self.apply_workspace_edit(workspace_edit) {
Ok(count) => {
self.status_message = Some(format!("renamed: {count} files changed"));
}
Err(e) => {
self.status_message = Some(format!("LSP rename: {e}"));
}
}
}
pub(crate) fn lsp_format(&mut self) {
if self.lsp.is_none() {
self.status_message =
Some("LSP: not enabled (set [lsp] enabled = true in config)".into());
return;
}
let slot = self.active();
let path = match slot.filename.as_ref() {
Some(p) => absolutize(p),
None => {
self.status_message = Some(
"LSP: no file open in this buffer (use :e <file> or open from the picker)"
.into(),
);
return;
}
};
let uri = match hjkl_lsp::uri::from_path(&path).ok() {
Some(u) => u,
None => {
self.status_message = Some("LSP: cannot build URI".into());
return;
}
};
let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;
let tab_size = slot.editor.settings().tabstop as u32;
let insert_spaces = slot.editor.settings().expandtab;
let params = json!({
"textDocument": { "uri": uri.as_str() },
"options": {
"tabSize": tab_size,
"insertSpaces": insert_spaces,
},
});
let request_id = self.lsp_alloc_request_id();
self.lsp_pending.insert(
request_id,
LspPendingRequest::Format {
buffer_id,
range: None,
},
);
if let Some(mgr) = self.lsp.as_ref() {
mgr.send_request(request_id, buffer_id, "textDocument/formatting", params);
}
}
pub(crate) fn handle_format_response(
&mut self,
buffer_id: hjkl_lsp::BufferId,
_range: Option<(usize, usize, usize, usize)>,
result: Result<serde_json::Value, hjkl_lsp::RpcError>,
) {
let val = match result {
Ok(v) => v,
Err(e) => {
self.status_message = Some(format!("LSP format: {}", e.message));
return;
}
};
if val.is_null() {
self.status_message = Some("no formatting changes".into());
return;
}
let edits: Vec<lsp_types::TextEdit> = match serde_json::from_value(val) {
Ok(e) => e,
Err(_) => {
self.status_message = Some("LSP format: could not parse response".into());
return;
}
};
if edits.is_empty() {
self.status_message = Some("no formatting changes".into());
return;
}
let slot_idx = self
.slots
.iter()
.position(|s| s.buffer_id as hjkl_lsp::BufferId == buffer_id);
let slot_idx = match slot_idx {
Some(i) => i,
None => {
self.status_message = Some("LSP format: buffer no longer open".into());
return;
}
};
let mut sorted = edits;
sorted.sort_by(|a, b| {
let ea = (a.range.end.line, a.range.end.character);
let eb = (b.range.end.line, b.range.end.character);
eb.cmp(&ea)
});
use hjkl_engine::{BufferEdit, Pos};
for te in sorted {
let start = Pos {
line: te.range.start.line,
col: te.range.start.character,
};
let end = Pos {
line: te.range.end.line,
col: te.range.end.character,
};
BufferEdit::replace_range(
self.slots[slot_idx].editor.buffer_mut(),
start..end,
&te.new_text,
);
}
let _ = self.slots[slot_idx].editor.take_dirty();
self.slots[slot_idx].dirty = true;
self.status_message = Some("formatted".into());
}
pub(crate) fn handle_completion_response(
&mut self,
buffer_id: hjkl_lsp::BufferId,
anchor_row: usize,
anchor_col: usize,
result: Result<serde_json::Value, hjkl_lsp::RpcError>,
) {
let val = match result {
Ok(v) => v,
Err(e) => {
self.status_message = Some(format!("LSP completion: {}", e.message));
return;
}
};
use hjkl_engine::VimMode;
if self.active().editor.vim_mode() != VimMode::Insert {
return;
}
if (self.active().buffer_id as hjkl_lsp::BufferId) != buffer_id {
return;
}
let lsp_items: Vec<lsp_types::CompletionItem> = if val.is_null() {
Vec::new()
} else if let Ok(list) = serde_json::from_value::<lsp_types::CompletionList>(val.clone()) {
list.items
} else {
serde_json::from_value::<Vec<lsp_types::CompletionItem>>(val).unwrap_or_default()
};
if lsp_items.is_empty() {
self.status_message = Some("no completions".into());
return;
}
let items: Vec<crate::completion::CompletionItem> =
lsp_items.into_iter().map(item_from_lsp).collect();
self.completion = Some(Completion::new(anchor_row, anchor_col, items));
}
pub(crate) fn accept_completion(&mut self) {
let popup = match self.completion.take() {
Some(p) => p,
None => return,
};
let item = match popup.selected_item() {
Some(i) => i.clone(),
None => return,
};
use hjkl_engine::{BufferEdit, Pos};
let cursor = self.active().editor.buffer().cursor();
let row = cursor.row;
let cur_col = cursor.col;
let anchor_col = popup.anchor_col.min(cur_col);
let start = Pos {
line: row as u32,
col: anchor_col as u32,
};
let end = Pos {
line: row as u32,
col: cur_col as u32,
};
BufferEdit::replace_range(
self.active_mut().editor.buffer_mut(),
start..end,
&item.insert_text,
);
let new_col = anchor_col + item.insert_text.len();
self.active_mut().editor.jump_cursor(row, new_col);
}
pub(crate) fn handle_hover_response(
&mut self,
_buffer_id: hjkl_lsp::BufferId,
_origin: (usize, usize),
result: Result<serde_json::Value, hjkl_lsp::RpcError>,
) {
let val = match result {
Ok(v) => v,
Err(e) => {
self.status_message = Some(format!("LSP hover: {}", e.message));
return;
}
};
if val.is_null() {
self.status_message = Some("no hover info".into());
return;
}
let hover: lsp_types::Hover = match serde_json::from_value(val) {
Ok(h) => h,
Err(_) => {
self.status_message = Some("LSP hover: could not parse response".into());
return;
}
};
let text = extract_hover_text(&hover.contents);
if text.trim().is_empty() {
self.status_message = Some("no hover info".into());
} else {
self.info_popup = Some(text);
}
}
pub(crate) fn handle_hover_at_mouse_response(
&mut self,
_buffer_id: hjkl_lsp::BufferId,
_origin: (usize, usize),
result: Result<serde_json::Value, hjkl_lsp::RpcError>,
) {
if self.overlay_active() {
return;
}
let timer_cell = match &self.hover_timer {
Some(t) if t.request_sent => t.cell,
_ => return, };
let val = match result {
Ok(v) => v,
Err(_) => return, };
if val.is_null() {
return; }
let hover: lsp_types::Hover = match serde_json::from_value(val) {
Ok(h) => h,
Err(_) => return,
};
let text = extract_hover_text(&hover.contents);
if text.trim().is_empty() {
return;
}
self.hover_popup = Some(crate::hover_popup::HoverPopup::new(text, timer_cell));
}
}
fn extract_hover_text(contents: &lsp_types::HoverContents) -> String {
match contents {
lsp_types::HoverContents::Scalar(ms) => marked_string_text(ms),
lsp_types::HoverContents::Array(items) => items
.iter()
.map(marked_string_text)
.collect::<Vec<_>>()
.join("\n\n"),
lsp_types::HoverContents::Markup(mc) => strip_markdown(&mc.value),
}
}
fn marked_string_text(ms: &lsp_types::MarkedString) -> String {
match ms {
lsp_types::MarkedString::String(s) => strip_markdown(s),
lsp_types::MarkedString::LanguageString(ls) => {
format!("[{}]\n{}", ls.language, ls.value)
}
}
}
fn strip_markdown(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_fence = false;
for line in s.lines() {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_fence = !in_fence;
if in_fence {
let lang = trimmed.trim_start_matches('`').trim();
if !lang.is_empty() {
out.push_str(lang);
out.push('\n');
}
}
continue;
}
let stripped: String = line.replace("**", "").replace(['*', '`'], "");
out.push_str(&stripped);
out.push('\n');
}
out
}
impl App {
pub(crate) fn dispatch_lsp_action(&mut self, action: crate::keymap_actions::AppAction) {
use crate::keymap_actions::AppAction;
match action {
AppAction::ShowDiagAtCursor => self.show_diag_at_cursor(),
AppAction::LspCodeActions => self.lsp_code_actions(),
AppAction::LspRename => {
self.status_message = Some("use :Rename <newname> to rename".into());
}
AppAction::LspGotoDef => self.lsp_goto_definition(),
AppAction::LspGotoDecl => self.lsp_goto_declaration(),
AppAction::LspGotoRef => self.lsp_goto_references(),
AppAction::LspGotoImpl => self.lsp_goto_implementation(),
AppAction::LspGotoTypeDef => self.lsp_goto_type_definition(),
AppAction::LspHover => self.lsp_hover(),
AppAction::DiagNext => self.dispatch_ex("lnext"),
AppAction::DiagPrev => self.dispatch_ex("lprev"),
AppAction::DiagNextError => self.lnext_severity(Some(super::DiagSeverity::Error)),
AppAction::DiagPrevError => self.lprev_severity(Some(super::DiagSeverity::Error)),
_ => {}
}
}
}