use std::path::PathBuf;
use hjkl_buffer_tui::Sign;
use ratatui::style::{Color, Modifier, Style};
use serde_json::json;
use crate::completion::{Completion, item_from_lsp};
use super::{App, DiagSeverity, LspDiag, LspPendingRequest, LspServerInfo};
const LSP_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
const LSP_AUTO_COMPLETION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
fn pending_request_timeout(req: &LspPendingRequest) -> std::time::Duration {
match req {
LspPendingRequest::Completion { auto: true, .. } => LSP_AUTO_COMPLETION_TIMEOUT,
_ => LSP_REQUEST_TIMEOUT,
}
}
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())
}
}
fn capability_pointer_for_method(method: &str) -> Option<&'static str> {
Some(match method {
"textDocument/definition" => "/definitionProvider",
"textDocument/declaration" => "/declarationProvider",
"textDocument/typeDefinition" => "/typeDefinitionProvider",
"textDocument/implementation" => "/implementationProvider",
"textDocument/references" => "/referencesProvider",
"textDocument/hover" => "/hoverProvider",
"textDocument/completion" => "/completionProvider",
"textDocument/codeAction" => "/codeActionProvider",
"textDocument/rename" => "/renameProvider",
"textDocument/formatting" => "/documentFormattingProvider",
_ => return None,
})
}
fn snap_to_char_boundary(rope: &ropey::Rope, byte: usize) -> usize {
let b = byte.min(rope.len_bytes());
rope.char_to_byte(rope.byte_to_char(b))
}
pub(crate) fn col_to_wire(
line: &str,
col_chars: usize,
encoding: hjkl_lsp::PositionEncoding,
) -> u32 {
line.chars()
.take(col_chars)
.map(|c| match encoding {
hjkl_lsp::PositionEncoding::Utf8 => c.len_utf8(),
hjkl_lsp::PositionEncoding::Utf16 => c.len_utf16(),
})
.sum::<usize>() as u32
}
pub(crate) fn wire_to_col(
line: &str,
wire_col: u32,
encoding: hjkl_lsp::PositionEncoding,
) -> usize {
let target = wire_col as usize;
let mut consumed = 0usize;
for (idx, c) in line.chars().enumerate() {
if consumed == target {
return idx;
}
let width = match encoding {
hjkl_lsp::PositionEncoding::Utf8 => c.len_utf8(),
hjkl_lsp::PositionEncoding::Utf16 => c.len_utf16(),
};
if consumed + width > target {
return idx;
}
consumed += width;
}
line.chars().count()
}
fn wire_position_to_pos(
rope: &ropey::Rope,
line: u32,
character: u32,
encoding: hjkl_lsp::PositionEncoding,
) -> hjkl_engine::Pos {
let col = if (line as usize) < rope.len_lines() {
let text = hjkl_buffer::rope_line_str(rope, line as usize);
wire_to_col(&text, character, encoding) as u32
} else {
character
};
hjkl_engine::Pos { line, col }
}
fn edits_ascending_disjoint(edits: &[hjkl_engine::ContentEdit]) -> bool {
edits
.windows(2)
.all(|w| w[0].new_end_byte <= w[1].start_byte)
}
fn build_text_changes(
rope: &ropey::Rope,
edits: &[hjkl_engine::ContentEdit],
) -> Option<Vec<hjkl_lsp::TextChange>> {
if !edits_ascending_disjoint(edits) {
return None;
}
let len_bytes = rope.len_bytes();
Some(
edits
.iter()
.map(|e| {
let start = snap_to_char_boundary(rope, e.start_byte.min(len_bytes));
let end = snap_to_char_boundary(rope, e.new_end_byte.min(len_bytes)).max(start);
let text = rope.byte_slice(start..end).to_string();
hjkl_lsp::TextChange {
start_line: e.start_position.0,
start_col: e.start_position.1,
end_line: e.old_end_position.0,
end_col: e.old_end_position.1,
text,
}
})
.collect(),
)
}
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);
if let Some(mgr) = &self.lsp {
mgr.server_exited(key);
}
}
hjkl_lsp::LspEvent::Notification {
key,
method,
params,
} => {
tracing::debug!(?key, method, "lsp notification");
if method == "textDocument/publishDiagnostics" {
let encoding = self
.lsp_state
.get(&key)
.map(|info| {
hjkl_lsp::PositionEncoding::from_capabilities(&info.capabilities)
})
.unwrap_or_default();
self.handle_publish_diagnostics(params, encoding);
}
}
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);
}
}
}
}
self.sweep_stale_lsp_pending_at(std::time::Instant::now());
}
pub(crate) fn sweep_stale_lsp_pending_at(&mut self, now: std::time::Instant) {
let ids: Vec<i64> = self.lsp_pending.keys().copied().collect();
for id in ids {
self.lsp_pending_seen_at.entry(id).or_insert(now);
}
let mut drop_ids: Vec<i64> = Vec::new();
for (id, seen) in self.lsp_pending_seen_at.iter() {
let timed_out = match self.lsp_pending.get(id) {
Some(req) => now.saturating_duration_since(*seen) >= pending_request_timeout(req),
None => true, };
if timed_out {
drop_ids.push(*id);
}
}
for id in drop_ids {
if self.lsp_pending.remove(&id).is_some() {
tracing::warn!(request_id = id, "lsp request timed out; dropping pending");
}
self.lsp_pending_seen_at.remove(&id);
}
}
pub(crate) fn handle_publish_diagnostics(
&mut self,
params: serde_json::Value,
encoding: hjkl_lsp::PositionEncoding,
) {
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();
let rope = self.slots[slot_idx].buffer().rope();
for d in &parsed.diagnostics {
let start_row = d.range.start.line as usize;
let end_row = d.range.end.line as usize;
let start_col = if start_row < rope.len_lines() {
wire_to_col(
&hjkl_buffer::rope_line_str(&rope, start_row),
d.range.start.character,
encoding,
)
} else {
d.range.start.character as usize
};
let end_col = if end_row < rope.len_lines() {
wire_to_col(
&hjkl_buffer::rope_line_str(&rope, end_row),
d.range.end.character,
encoding,
)
} else {
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<Sign> = sign_map
.into_iter()
.map(|(row, (_, ch, style, priority))| Sign {
row,
ch,
style,
priority,
})
.collect();
tracing::debug!(
slot = slot_idx,
n_diags = lsp_diags.len(),
n_signs = diag_signs_lsp.len(),
"lsp publishDiagnostics applied"
);
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, edits: &[hjkl_engine::ContentEdit]) {
let slot_idx = self.focused_slot_idx();
self.lsp_notify_change_for_slot(slot_idx, edits);
}
pub(crate) fn lsp_notify_change_for_slot(
&mut self,
slot_idx: usize,
edits: &[hjkl_engine::ContentEdit],
) {
if self.lsp.as_ref().is_none() {
return;
}
let use_incremental = !edits.is_empty()
&& self.lsp_supports_incremental_utf8_for_slot(slot_idx)
&& edits_ascending_disjoint(edits);
let mgr = self.lsp.as_ref().unwrap();
let Some(slot) = self.slots.get_mut(slot_idx) else {
return;
};
let dg = slot.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;
if use_incremental {
let rope = slot.buffer().rope();
let changes = build_text_changes(&rope, edits).expect(
"use_incremental already checked edits_ascending_disjoint, \
so build_text_changes must succeed",
);
tracing::debug!(
buffer_id,
dg,
n_changes = changes.len(),
"lsp didChange incremental"
);
mgr.notify_change_incremental(buffer_id, changes);
} else {
let text = slot.buffer().content_joined();
tracing::debug!(
buffer_id,
dg,
n_edits = edits.len(),
text_len = text.len(),
"lsp didChange full"
);
mgr.notify_change(buffer_id, text);
}
}
pub(crate) fn lsp_notify_save_slot(&mut self, slot_idx: usize) {
if self.lsp.is_none() {
return;
}
let Some(slot) = self.slots.get(slot_idx) else {
return;
};
let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;
let text = slot.buffer().content_joined();
let dg = slot.buffer().dirty_gen();
if let Some(mgr) = self.lsp.as_ref() {
mgr.notify_change(buffer_id, text);
mgr.notify_save(buffer_id);
}
self.slots[slot_idx].last_lsp_dirty_gen = Some(dg);
}
fn lsp_supports_incremental_utf8_for_slot(&self, slot_idx: usize) -> bool {
if self.position_encoding_for_slot(slot_idx) != hjkl_lsp::PositionEncoding::Utf8 {
return false;
}
let Some(info) = self.lsp_server_info_for_slot(slot_idx) else {
return false;
};
let caps = &info.capabilities;
let sync = caps.get("textDocumentSync");
let change_kind = sync
.and_then(|v| {
v.as_i64()
.or_else(|| v.get("change").and_then(|c| c.as_i64()))
})
.unwrap_or(0);
change_kind == 2
}
fn lsp_server_info_for_slot(&self, slot_idx: usize) -> Option<&LspServerInfo> {
let lang = self
.slots
.get(slot_idx)
.and_then(|s| s.filename.as_ref())
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.and_then(language_id_for_ext)?;
self.lsp_state
.iter()
.find(|(k, _)| k.language == lang)
.map(|(_, info)| info)
}
pub(crate) fn position_encoding_for_slot(&self, slot_idx: usize) -> hjkl_lsp::PositionEncoding {
self.lsp_server_info_for_slot(slot_idx)
.map(|info| hjkl_lsp::PositionEncoding::from_capabilities(&info.capabilities))
.unwrap_or_default()
}
pub(crate) fn active_position_encoding(&self) -> hjkl_lsp::PositionEncoding {
self.position_encoding_for_slot(self.focused_slot_idx())
}
fn lsp_active_supports(&self, pointer: &str) -> bool {
let Some(lang) = self
.active()
.filename
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.and_then(language_id_for_ext)
else {
return false;
};
self.lsp_state.iter().any(|(k, info)| {
k.language == lang
&& info.initialized
&& info
.capabilities
.pointer(pointer)
.is_some_and(|v| *v != serde_json::Value::Bool(false))
})
}
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_comment_lead(&self) -> &'static str {
self.active()
.filename
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.and_then(language_id_for_ext)
.and_then(hjkl_lang::comment::commentstring_for_lang)
.map(|(start, _)| start)
.unwrap_or("//")
}
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 fn shutdown(&mut self) {
if let Some(lsp) = self.lsp.take() {
lsp.shutdown();
}
}
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.bus.info("LSP restarted");
}
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) {
if !self.slots[slot_idx].features.lsp {
return;
}
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].buffer().content_joined();
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),
hjkl_lsp::PositionEncoding,
)> {
let slot = self.active();
let path = absolutize(slot.filename.as_ref()?);
let uri = hjkl_lsp::uri::from_path(&path).ok()?;
let cursor = self.active_editor().buffer().cursor();
let row = cursor.row;
let col = cursor.col;
let encoding = self.active_position_encoding();
let line = hjkl_buffer::rope_line_str(&self.active_editor().buffer().rope(), row);
let wire_col = col_to_wire(&line, col, encoding);
let params = json!({
"textDocument": { "uri": uri.as_str() },
"position": { "line": row as u32, "character": wire_col },
});
let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;
Some((params, buffer_id, (row, col), encoding))
}
fn lsp_send_goto(
&mut self,
method: &str,
extras: Option<serde_json::Value>,
make_pending: impl FnOnce(
hjkl_lsp::BufferId,
(usize, usize),
hjkl_lsp::PositionEncoding,
) -> LspPendingRequest,
) {
if self.lsp.is_none() {
self.bus
.error("LSP: not enabled (set [lsp] enabled = true in config)");
return;
}
if let Some(ptr) = capability_pointer_for_method(method)
&& !self.lsp_active_supports(ptr)
{
self.bus
.error(format!("LSP: server does not support {method}"));
return;
}
let (mut params, buffer_id, origin, encoding) = match self.lsp_position_params() {
Some(v) => v,
None => {
self.bus.error(
"LSP: no file open in this buffer (use :e <file> or open from the picker)",
);
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, encoding);
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, encoding| {
LspPendingRequest::GotoDefinition {
buffer_id: buf,
origin: orig,
encoding,
}
});
}
pub(crate) fn lsp_goto_declaration(&mut self) {
self.lsp_send_goto("textDocument/declaration", None, |buf, orig, encoding| {
LspPendingRequest::GotoDeclaration {
buffer_id: buf,
origin: orig,
encoding,
}
});
}
pub(crate) fn lsp_goto_type_definition(&mut self) {
self.lsp_send_goto(
"textDocument/typeDefinition",
None,
|buf, orig, encoding| LspPendingRequest::GotoTypeDefinition {
buffer_id: buf,
origin: orig,
encoding,
},
);
}
pub(crate) fn lsp_goto_implementation(&mut self) {
self.lsp_send_goto(
"textDocument/implementation",
None,
|buf, orig, encoding| LspPendingRequest::GotoImplementation {
buffer_id: buf,
origin: orig,
encoding,
},
);
}
pub(crate) fn lsp_goto_references(&mut self) {
self.lsp_send_goto(
"textDocument/references",
Some(json!({ "context": { "includeDeclaration": true } })),
|buf, orig, encoding| LspPendingRequest::GotoReferences {
buffer_id: buf,
origin: orig,
encoding,
},
);
}
pub(crate) fn lsp_hover(&mut self) {
if !self.active().features.hover {
return;
}
if self.active_editor().is_blame() {
let (row, col) = self.active_editor().cursor();
let win_id = self.focused_window();
let cell = crate::app::mouse::doc_to_cell(self, win_id, row, col).unwrap_or((0, 0));
self.show_blame_commit_hover(row, cell);
return;
}
self.lsp_send_goto("textDocument/hover", None, |buf, orig, _encoding| {
LspPendingRequest::Hover {
buffer_id: buf,
origin: orig,
}
});
}
pub(crate) fn lsp_hover_at_doc(&mut self, doc_row: usize, doc_col: usize) {
if !self.active().features.hover {
return;
}
if self.lsp.is_none() {
return; }
if !self.lsp_active_supports("/hoverProvider") {
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 encoding = self.active_position_encoding();
let line = hjkl_buffer::rope_line_str(&self.active_editor().buffer().rope(), doc_row);
let wire_col = col_to_wire(&line, doc_col, encoding);
let params = serde_json::json!({
"textDocument": { "uri": uri.as_str() },
"position": { "line": doc_row as u32, "character": wire_col },
});
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,
encoding,
} => {
self.handle_goto_response(buffer_id, origin, result, "definition", encoding);
}
LspPendingRequest::GotoDeclaration {
buffer_id,
origin,
encoding,
} => {
self.handle_goto_response(buffer_id, origin, result, "declaration", encoding);
}
LspPendingRequest::GotoTypeDefinition {
buffer_id,
origin,
encoding,
} => {
self.handle_goto_response(buffer_id, origin, result, "type definition", encoding);
}
LspPendingRequest::GotoImplementation {
buffer_id,
origin,
encoding,
} => {
self.handle_goto_response(buffer_id, origin, result, "implementation", encoding);
}
LspPendingRequest::GotoReferences {
buffer_id,
origin,
encoding,
} => {
self.handle_references_response(buffer_id, origin, result, encoding);
}
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,
auto,
} => {
self.handle_completion_response(buffer_id, anchor_row, anchor_col, auto, result);
}
LspPendingRequest::CodeAction {
buffer_id,
anchor_row,
anchor_col,
encoding,
} => {
self.handle_code_action_response(
buffer_id, anchor_row, anchor_col, result, encoding,
);
}
LspPendingRequest::Rename {
buffer_id,
anchor_row,
anchor_col,
new_name,
encoding,
} => {
self.handle_rename_response(
buffer_id, anchor_row, anchor_col, new_name, result, encoding,
);
}
LspPendingRequest::Format {
buffer_id,
range,
encoding,
} => {
self.handle_format_response(buffer_id, range, result, encoding);
}
}
}
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 line_text_for_path(&self, path: &std::path::Path, row: usize) -> Option<String> {
if let Some(slot) = self.slots.iter().find(|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 == path
})
.unwrap_or(false)
}) {
let rope = slot.buffer().rope();
return if row < rope.len_lines() {
Some(hjkl_buffer::rope_line_str(&rope, row).to_string())
} else {
None
};
}
let text = std::fs::read_to_string(path).ok()?;
text.lines().nth(row).map(|s| s.to_string())
}
fn convert_location_start(
&self,
path: &std::path::Path,
loc: &lsp_types::Location,
encoding: hjkl_lsp::PositionEncoding,
) -> (usize, usize) {
let row = loc.range.start.line as usize;
let wire_col = loc.range.start.character;
let col = match self.line_text_for_path(path, row) {
Some(line) => wire_to_col(&line, wire_col, encoding),
None => wire_col as usize,
};
(row, col)
}
fn jump_to_location(
&mut self,
loc: &lsp_types::Location,
encoding: hjkl_lsp::PositionEncoding,
) {
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, col) = match target_path.as_ref() {
Some(tp) => self.convert_location_start(tp, loc, encoding),
None => (
loc.range.start.line as usize,
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.bus.error(format!("LSP goto: {e}"));
return;
}
}
} else {
self.bus.error("LSP goto: non-file URI");
return;
}
self.active_editor_mut().jump_cursor(row, col);
self.active_editor_mut().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,
encoding: hjkl_lsp::PositionEncoding,
) {
let val = match result {
Ok(v) => v,
Err(e) => {
self.bus.error(format!("LSP {kind_label}: {}", e.message));
return;
}
};
let locs = Self::parse_goto_locations(val);
if locs.is_empty() {
self.bus.warn(format!("no {kind_label} found"));
return;
}
if locs.len() == 1 {
self.jump_to_location(&locs[0], encoding);
} else {
self.open_lsp_locations_picker(&locs, kind_label, encoding);
}
}
pub(crate) fn handle_references_response(
&mut self,
_buffer_id: hjkl_lsp::BufferId,
_origin: (usize, usize),
result: Result<serde_json::Value, hjkl_lsp::RpcError>,
encoding: hjkl_lsp::PositionEncoding,
) {
let val = match result {
Ok(v) => v,
Err(e) => {
self.bus.error(format!("LSP references: {}", e.message));
return;
}
};
let locs = Self::parse_goto_locations(val);
if locs.is_empty() {
self.bus.warn("no references found");
return;
}
self.set_loclist_from_locations(&locs, encoding);
self.open_lsp_locations_picker(&locs, "references", encoding);
}
fn set_loclist_from_locations(
&mut self,
locs: &[lsp_types::Location],
encoding: hjkl_lsp::PositionEncoding,
) {
let entries: Vec<hjkl_quickfix::QfEntry> = 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, col) = self.convert_location_start(&path, loc, encoding);
Some(hjkl_quickfix::QfEntry {
path,
row,
col,
kind: hjkl_quickfix::QfKind::Info,
message: "reference".to_string(),
})
})
.collect();
self.set_loclist(entries);
}
fn open_lsp_locations_picker(
&mut self,
locs: &[lsp_types::Location],
kind_label: &str,
encoding: hjkl_lsp::PositionEncoding,
) {
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, col) = self.convert_location_start(&path, loc, encoding);
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 as u32 + 1)))
})
.collect();
if entries.is_empty() {
self.bus.warn(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));
}
fn active_completion_provider(&self) -> Option<&serde_json::Value> {
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.iter().find_map(|(key, info)| {
(key.language == lang)
.then(|| info.capabilities.pointer("/completionProvider"))
.flatten()
})
}
pub(crate) fn slot_has_lsp(&self, slot_idx: usize) -> bool {
let Some(slot) = self.slots.get(slot_idx) else {
return false;
};
let lang = slot
.filename
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.and_then(language_id_for_ext);
match lang {
Some(l) => self.lsp_state.keys().any(|k| k.language == l),
None => false,
}
}
fn lsp_has_pending_completion(&self) -> bool {
self.lsp_pending
.values()
.any(|p| matches!(p, LspPendingRequest::Completion { .. }))
}
pub(crate) fn maybe_auto_trigger_completion(&mut self, ch: char) {
let (has_provider, is_trigger) = match self.active_completion_provider() {
Some(p) => {
let is_trigger = p
.pointer("/triggerCharacters")
.and_then(|v| v.as_array())
.is_some_and(|arr| {
arr.iter()
.any(|s| s.as_str() == Some(ch.to_string().as_str()))
});
(true, is_trigger)
}
None => (false, false),
};
let is_ident = ch.is_alphanumeric() || ch == '_';
if !(is_trigger || is_ident) {
return;
}
if is_ident {
self.open_buffer_word_completion();
}
if has_provider && !self.lsp_has_pending_completion() {
self.lsp_request_completion_inner(true);
}
}
pub(crate) fn open_buffer_word_completion(&mut self) {
let cursor = self.active_editor().buffer().cursor();
let (row, col) = (cursor.row, cursor.col);
let anchor_col = self.identifier_start_col(row, col);
let token = self.token_between(row, anchor_col, col);
let items = self.buffer_word_items(&token);
if items.is_empty() {
return;
}
let mut popup = Completion::new(row, anchor_col, items);
if !token.is_empty() {
popup.set_prefix(&token);
if popup.is_empty() {
return;
}
}
self.completion = Some(popup);
}
pub(crate) fn token_between(&self, row: usize, lo: usize, hi: usize) -> String {
let rope = self.active_editor().buffer().rope();
if row >= rope.len_lines() {
return String::new();
}
let line = hjkl_buffer::rope_line_str(&rope, row);
let char_count = line.chars().count();
let lo = lo.min(char_count);
let hi = hi.min(char_count);
if lo > hi {
return String::new();
}
let byte_lo = hjkl_buffer::Position::new(row, lo).byte_offset(&line);
let byte_hi = hjkl_buffer::Position::new(row, hi).byte_offset(&line);
line[byte_lo..byte_hi].to_string()
}
pub(crate) fn buffer_word_items(
&self,
exclude: &str,
) -> Vec<crate::completion::CompletionItem> {
use crate::completion::CompletionItem;
const MAX_WORDS: usize = 2000;
const MAX_SCAN_BYTES: usize = 1_000_000;
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut items: Vec<CompletionItem> = Vec::new();
let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
let is_word_start = |c: char| c.is_alphabetic() || c == '_';
'buffers: for slot in &self.slots {
let rope = slot.buffer().rope();
let mut word = String::new();
let mut scanned = 0usize;
for ch in rope.chars() {
scanned += ch.len_utf8();
if is_word_char(ch) {
word.push(ch);
} else if !word.is_empty() {
Self::push_buffer_word(
&mut word,
exclude,
&mut seen,
&mut items,
is_word_start,
);
if items.len() >= MAX_WORDS {
break 'buffers;
}
}
if scanned >= MAX_SCAN_BYTES {
break;
}
}
if !word.is_empty() {
Self::push_buffer_word(&mut word, exclude, &mut seen, &mut items, is_word_start);
if items.len() >= MAX_WORDS {
break 'buffers;
}
}
}
items
}
fn push_buffer_word(
word: &mut String,
exclude: &str,
seen: &mut std::collections::HashSet<String>,
items: &mut Vec<crate::completion::CompletionItem>,
is_word_start: impl Fn(char) -> bool,
) {
if word.len() >= 2
&& word.as_str() != exclude
&& word.chars().next().is_some_and(&is_word_start)
&& seen.insert(word.clone())
{
items.push(crate::completion::CompletionItem::new(word.clone()));
}
word.clear();
}
pub(crate) fn lsp_request_completion(&mut self) {
self.lsp_request_completion_inner(false);
}
fn lsp_request_completion_inner(&mut self, auto: bool) {
if self.lsp.is_none() {
if !auto {
self.bus
.error("LSP: not enabled (set [lsp] enabled = true in config)");
}
return;
}
if !self.lsp_active_supports("/completionProvider") {
if !auto {
self.bus.error("LSP: server has no completion support");
}
return;
}
let (params, buffer_id, (row, col), _encoding) = match self.lsp_position_params() {
Some(v) => v,
None => {
if !auto {
self.bus.error(
"LSP: no file open in this buffer (use :e <file> or open from the picker)",
);
}
return;
}
};
let anchor_col = self.identifier_start_col(row, col);
let request_id = self.lsp_alloc_request_id();
self.lsp_pending.insert(
request_id,
LspPendingRequest::Completion {
buffer_id,
anchor_row: row,
anchor_col,
auto,
},
);
if let Some(mgr) = self.lsp.as_ref() {
mgr.send_request(request_id, buffer_id, "textDocument/completion", params);
}
}
pub(crate) fn identifier_start_col(&self, row: usize, col: usize) -> usize {
let rope = self.active_editor().buffer().rope();
if row >= rope.len_lines() {
return col;
}
let line = hjkl_buffer::rope_line_str(&rope, row);
let chars: Vec<char> = line.chars().collect();
let end = col.min(chars.len());
let mut start = end;
for i in (0..end).rev() {
if chars[i].is_alphanumeric() || chars[i] == '_' {
start = i;
} else {
break;
}
}
start
}
pub(crate) fn lsp_code_actions(&mut self) {
if self.lsp.is_none() {
self.bus
.error("LSP: not enabled (set [lsp] enabled = true in config)");
return;
}
if !self.lsp_active_supports("/codeActionProvider") {
self.bus.error("LSP: server has no code-action support");
return;
}
let slot = self.active();
let path = match slot.filename.as_ref() {
Some(p) => absolutize(p),
None => {
self.bus.error(
"LSP: no file open in this buffer (use :e <file> or open from the picker)",
);
return;
}
};
let uri = match hjkl_lsp::uri::from_path(&path).ok() {
Some(u) => u,
None => {
self.bus.error("LSP: cannot build URI");
return;
}
};
let cursor = self.active_editor().buffer().cursor();
let encoding = self.active_position_encoding();
let rope = self.active_editor().buffer().rope();
let cursor_line = hjkl_buffer::rope_line_str(&rope, cursor.row);
let wire_col = col_to_wire(&cursor_line, cursor.col, encoding);
let row = cursor.row 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())
}
});
let start_line_text = hjkl_buffer::rope_line_str(&rope, d.start_row);
let end_line_text = hjkl_buffer::rope_line_str(&rope, d.end_row);
lsp_types::Diagnostic {
range: lsp_types::Range {
start: lsp_types::Position {
line: d.start_row as u32,
character: col_to_wire(&start_line_text, d.start_col, encoding),
},
end: lsp_types::Position {
line: d.end_row as u32,
character: col_to_wire(&end_line_text, d.end_col, encoding),
},
},
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": wire_col },
"end": { "line": row, "character": wire_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,
encoding,
},
);
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>,
encoding: hjkl_lsp::PositionEncoding,
) {
let val = match result {
Ok(v) => v,
Err(e) => {
self.bus.error(format!("LSP codeAction: {}", e.message));
return;
}
};
if val.is_null() {
self.bus.warn("no code actions");
return;
}
let actions: Vec<lsp_types::CodeActionOrCommand> = match serde_json::from_value(val) {
Ok(a) => a,
Err(_) => {
self.bus.error("LSP codeAction: could not parse response");
return;
}
};
if actions.is_empty() {
self.bus.warn("no code actions");
return;
}
if actions.len() == 1 {
let action = actions.into_iter().next().unwrap();
self.apply_code_action_or_command(action, encoding);
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;
self.pending_code_actions_encoding = encoding;
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,
encoding: hjkl_lsp::PositionEncoding,
) {
match item {
lsp_types::CodeActionOrCommand::CodeAction(ca) => {
if let Some(edit) = ca.edit {
match self.apply_workspace_edit(edit, encoding) {
Ok(count) => {
self.bus.info(format!("{count} files changed"));
}
Err(e) => {
self.bus.error(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,
encoding: hjkl_lsp::PositionEncoding,
) -> 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 workspace_roots: Vec<std::path::PathBuf> = if self.lsp_state.is_empty() {
std::env::current_dir().ok().into_iter().collect()
} else {
self.lsp_state.keys().map(|k| k.root.clone()).collect()
};
let mut out_of_root: Vec<std::path::PathBuf> = Vec::new();
let count = file_edits.len();
for (url, mut edits) in file_edits {
let target_path = hjkl_lsp::uri::to_path(&url);
if let Some(ref tp) = target_path {
let canon = std::fs::canonicalize(tp).unwrap_or_else(|_| tp.clone());
let in_root = workspace_roots.iter().any(|r| {
let rc = std::fs::canonicalize(r).unwrap_or_else(|_| r.clone());
tp.starts_with(r) || canon.starts_with(&rc)
});
if !in_root {
out_of_root.push(tp.clone());
}
}
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;
for te in edits {
let rope = self.slots[slot_idx].buffer().rope();
let start = wire_position_to_pos(
&rope,
te.range.start.line,
te.range.start.character,
encoding,
);
let end = wire_position_to_pos(
&rope,
te.range.end.line,
te.range.end.character,
encoding,
);
BufferEdit::replace_range(
self.slots[slot_idx].buffer_mut(),
start..end,
&te.new_text,
);
}
let _ = self.slots[slot_idx].take_dirty();
self.slots[slot_idx].dirty = true;
let buffer_id = self.slots[slot_idx].buffer_id;
if self.slots[slot_idx].take_content_reset() {
self.syntax.reset(buffer_id);
}
let slot_edits = self.slots[slot_idx].take_content_edits();
if !slot_edits.is_empty() {
self.syntax.apply_edits(buffer_id, &slot_edits);
}
self.lsp_notify_change_for_slot(slot_idx, &slot_edits);
}
self.pending_recompute = true;
if !out_of_root.is_empty() {
let names = out_of_root
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
self.bus.warn(format!(
"LSP: workspace edit modified file(s) OUTSIDE the workspace root: {names} — review before saving"
));
}
Ok(count)
}
pub(crate) fn lsp_rename(&mut self, new_name: String) {
if self.lsp.is_none() {
self.bus
.error("LSP: not enabled (set [lsp] enabled = true in config)");
return;
}
if !self.lsp_active_supports("/renameProvider") {
self.bus.error("LSP: server has no rename support");
return;
}
let slot = self.active();
let path = match slot.filename.as_ref() {
Some(p) => absolutize(p),
None => {
self.bus.error(
"LSP: no file open in this buffer (use :e <file> or open from the picker)",
);
return;
}
};
let uri = match hjkl_lsp::uri::from_path(&path).ok() {
Some(u) => u,
None => {
self.bus.error("LSP: cannot build URI");
return;
}
};
let cursor = self.active_editor().buffer().cursor();
let encoding = self.active_position_encoding();
let line = hjkl_buffer::rope_line_str(&self.active_editor().buffer().rope(), cursor.row);
let wire_col = col_to_wire(&line, cursor.col, encoding);
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": wire_col },
"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,
encoding,
},
);
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>,
encoding: hjkl_lsp::PositionEncoding,
) {
let val = match result {
Ok(v) => v,
Err(e) => {
self.bus.error(format!("LSP rename: {}", e.message));
return;
}
};
if val.is_null() {
self.bus.error("E: cannot rename here");
return;
}
let workspace_edit: lsp_types::WorkspaceEdit = match serde_json::from_value(val) {
Ok(we) => we,
Err(_) => {
self.bus.error("LSP rename: could not parse response");
return;
}
};
match self.apply_workspace_edit(workspace_edit, encoding) {
Ok(count) => {
self.bus.info(format!("renamed: {count} files changed"));
}
Err(e) => {
self.bus.error(format!("LSP rename: {e}"));
}
}
}
pub(crate) fn lsp_format(&mut self) {
if self.lsp.is_none() {
self.bus
.error("LSP: not enabled (set [lsp] enabled = true in config)");
return;
}
if !self.lsp_active_supports("/documentFormattingProvider") {
self.bus.error("LSP: server has no formatting support");
return;
}
let slot = self.active();
let path = match slot.filename.as_ref() {
Some(p) => absolutize(p),
None => {
self.bus.error(
"LSP: no file open in this buffer (use :e <file> or open from the picker)",
);
return;
}
};
let uri = match hjkl_lsp::uri::from_path(&path).ok() {
Some(u) => u,
None => {
self.bus.error("LSP: cannot build URI");
return;
}
};
let buffer_id = slot.buffer_id as hjkl_lsp::BufferId;
let tab_size = self.active_editor().settings().tabstop as u32;
let insert_spaces = self.active_editor().settings().expandtab;
let params = json!({
"textDocument": { "uri": uri.as_str() },
"options": {
"tabSize": tab_size,
"insertSpaces": insert_spaces,
},
});
let encoding = self.active_position_encoding();
let request_id = self.lsp_alloc_request_id();
self.lsp_pending.insert(
request_id,
LspPendingRequest::Format {
buffer_id,
range: None,
encoding,
},
);
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>,
encoding: hjkl_lsp::PositionEncoding,
) {
let val = match result {
Ok(v) => v,
Err(e) => {
self.bus.error(format!("LSP format: {}", e.message));
return;
}
};
if val.is_null() {
self.bus.warn("no formatting changes");
return;
}
let edits: Vec<lsp_types::TextEdit> = match serde_json::from_value(val) {
Ok(e) => e,
Err(_) => {
self.bus.error("LSP format: could not parse response");
return;
}
};
if edits.is_empty() {
self.bus.warn("no formatting changes");
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.bus.error("LSP format: buffer no longer open");
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;
for te in sorted {
let rope = self.slots[slot_idx].buffer().rope();
let start = wire_position_to_pos(
&rope,
te.range.start.line,
te.range.start.character,
encoding,
);
let end =
wire_position_to_pos(&rope, te.range.end.line, te.range.end.character, encoding);
BufferEdit::replace_range(self.slots[slot_idx].buffer_mut(), start..end, &te.new_text);
}
let _ = self.slots[slot_idx].take_dirty();
self.slots[slot_idx].dirty = true;
self.bus.info("formatted");
}
pub(crate) fn handle_completion_response(
&mut self,
buffer_id: hjkl_lsp::BufferId,
anchor_row: usize,
anchor_col: usize,
auto: bool,
result: Result<serde_json::Value, hjkl_lsp::RpcError>,
) {
let val = match result {
Ok(v) => v,
Err(e) => {
self.bus.error(format!("LSP completion: {}", e.message));
return;
}
};
use hjkl_engine::VimMode;
use hjkl_vim::VimEditorExt;
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()
};
let cursor = self.active_editor().buffer().cursor();
let prefix = if cursor.row == anchor_row && cursor.col >= anchor_col {
self.token_between(anchor_row, anchor_col, cursor.col)
} else {
String::new()
};
let mut items: Vec<crate::completion::CompletionItem> =
lsp_items.into_iter().map(item_from_lsp).collect();
let existing: std::collections::HashSet<String> =
items.iter().map(|i| i.label.clone()).collect();
for w in self.buffer_word_items(&prefix) {
if !existing.contains(&w.label) {
items.push(w);
}
}
if items.is_empty() {
if !auto {
self.bus.warn("no completions");
}
return;
}
let mut popup = Completion::new(anchor_row, anchor_col, items);
if !prefix.is_empty() {
popup.set_prefix(&prefix);
}
if popup.is_empty() {
return;
}
self.completion = Some(popup);
}
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,
};
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 raw_text = &item.insert_text;
let is_fn = matches!(
item.kind,
crate::completion::CompletionKind::Function | crate::completion::CompletionKind::Method
);
let (actual_text, cursor_offset) = if is_fn {
let stripped = if raw_text.ends_with("$0") {
&raw_text[..raw_text.len() - 2]
} else if raw_text.ends_with("${0}") {
&raw_text[..raw_text.len() - 4]
} else {
raw_text.as_str()
};
if let Some(paren_pos) = stripped.find('(') {
(stripped.to_string(), paren_pos + 1)
} else {
let with_parens = format!("{}()", stripped);
let offset = stripped.len() + 1; (with_parens, offset)
}
} else {
(raw_text.clone(), raw_text.len())
};
use hjkl_buffer::{Edit, Position};
self.active_editor_mut().mutate_edit(Edit::Replace {
start: Position::new(row, anchor_col),
end: Position::new(row, cur_col),
with: actual_text,
});
let new_col = anchor_col + cursor_offset;
self.active_editor_mut().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.bus.error(format!("LSP hover: {}", e.message));
return;
}
};
if val.is_null() {
self.bus.warn("no hover info");
return;
}
let hover: lsp_types::Hover = match serde_json::from_value(val) {
Ok(h) => h,
Err(_) => {
self.bus.error("LSP hover: could not parse response");
return;
}
};
let text = extract_hover_markdown(&hover.contents);
if text.trim().is_empty() {
self.bus.warn("no hover info");
} else {
let win_id = self.focused_window();
let (doc_row, doc_col) = origin;
let cell =
crate::app::mouse::doc_to_cell(self, win_id, doc_row, doc_col).unwrap_or((0, 0));
self.hover_popup = Some(hjkl_hover::HoverState::new(
text,
hjkl_hover::HoverAnchor::new(cell.0, cell.1),
));
}
}
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_markdown(&hover.contents);
if text.trim().is_empty() {
return;
}
self.hover_popup = Some(hjkl_hover::HoverState::new(
text,
hjkl_hover::HoverAnchor::new(timer_cell.0, timer_cell.1),
));
}
}
fn extract_hover_markdown(contents: &lsp_types::HoverContents) -> String {
match contents {
lsp_types::HoverContents::Scalar(ms) => marked_string_to_md(ms),
lsp_types::HoverContents::Array(items) => items
.iter()
.map(marked_string_to_md)
.collect::<Vec<_>>()
.join("\n\n"),
lsp_types::HoverContents::Markup(mc) => mc.value.clone(),
}
}
fn marked_string_to_md(ms: &lsp_types::MarkedString) -> String {
match ms {
lsp_types::MarkedString::String(s) => s.clone(),
lsp_types::MarkedString::LanguageString(ls) => {
format!("```{}\n{}\n```", ls.language, ls.value)
}
}
}
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.bus.info("use :Rename <newname> to rename");
}
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)),
_ => {}
}
}
}
#[cfg(test)]
mod position_encoding_tests {
use super::{col_to_wire, wire_to_col};
use hjkl_lsp::PositionEncoding;
#[test]
fn ascii_identity_both_encodings() {
let line = "let x = 1;";
for col in [0, 1, 5, line.len()] {
assert_eq!(col_to_wire(line, col, PositionEncoding::Utf8), col as u32);
assert_eq!(col_to_wire(line, col, PositionEncoding::Utf16), col as u32);
}
}
#[test]
fn two_byte_char_diverges_only_under_utf8() {
let line = "héllo";
assert_eq!(col_to_wire(line, 1, PositionEncoding::Utf8), 1);
assert_eq!(col_to_wire(line, 1, PositionEncoding::Utf16), 1);
assert_eq!(col_to_wire(line, 2, PositionEncoding::Utf8), 3);
assert_eq!(col_to_wire(line, 2, PositionEncoding::Utf16), 2);
assert_eq!(col_to_wire(line, 5, PositionEncoding::Utf8), 6);
assert_eq!(col_to_wire(line, 5, PositionEncoding::Utf16), 5);
}
#[test]
fn astral_emoji_diverges_under_both_encodings() {
let line = "a🎉b";
assert_eq!(col_to_wire(line, 2, PositionEncoding::Utf8), 5); assert_eq!(col_to_wire(line, 2, PositionEncoding::Utf16), 3); assert_eq!(col_to_wire(line, 3, PositionEncoding::Utf8), 6);
assert_eq!(col_to_wire(line, 3, PositionEncoding::Utf16), 4);
}
#[test]
fn mixed_line_utf8_and_utf16() {
let line = "héllo🎉x";
let byte_len_before_emoji_char = "héllo".len(); assert_eq!(
col_to_wire(line, 6, PositionEncoding::Utf8) as usize,
byte_len_before_emoji_char + 4
);
assert_eq!(col_to_wire(line, 6, PositionEncoding::Utf16), 5 + 2); }
#[test]
fn col_to_wire_overshoot_clamps_to_full_line() {
let line = "hé";
assert_eq!(
col_to_wire(line, 9999, PositionEncoding::Utf8),
line.len() as u32
);
assert_eq!(
col_to_wire(line, 9999, PositionEncoding::Utf16),
line.chars().map(|c| c.len_utf16()).sum::<usize>() as u32
);
}
#[test]
fn wire_to_col_ascii_identity() {
let line = "let x = 1;";
for wire in [0u32, 1, 5, line.len() as u32] {
assert_eq!(
wire_to_col(line, wire, PositionEncoding::Utf8),
wire as usize
);
assert_eq!(
wire_to_col(line, wire, PositionEncoding::Utf16),
wire as usize
);
}
}
#[test]
fn wire_to_col_two_byte_char() {
let line = "héllo";
assert_eq!(wire_to_col(line, 3, PositionEncoding::Utf8), 2);
assert_eq!(wire_to_col(line, 2, PositionEncoding::Utf16), 2);
}
#[test]
fn wire_to_col_astral_emoji() {
let line = "a🎉b";
assert_eq!(wire_to_col(line, 5, PositionEncoding::Utf8), 2);
assert_eq!(wire_to_col(line, 3, PositionEncoding::Utf16), 2);
}
#[test]
fn wire_to_col_overshoot_clamps_to_line_end() {
let line = "hé";
assert_eq!(
wire_to_col(line, 9999, PositionEncoding::Utf8),
line.chars().count()
);
assert_eq!(
wire_to_col(line, 9999, PositionEncoding::Utf16),
line.chars().count()
);
}
#[test]
fn wire_to_col_mid_char_snaps_to_char_start() {
let line = "a🎉b";
assert_eq!(wire_to_col(line, 2, PositionEncoding::Utf16), 1);
}
#[test]
fn round_trip_every_char_boundary() {
let line = "héllo🎉world_x";
let n = line.chars().count();
for enc in [PositionEncoding::Utf8, PositionEncoding::Utf16] {
for c in 0..=n {
let wire = col_to_wire(line, c, enc);
assert_eq!(
wire_to_col(line, wire, enc),
c,
"round-trip failed at char {c} under {enc:?}"
);
}
}
}
#[test]
fn round_trip_hash_comment_after_multibyte_string() {
let line = r#"s = "héllo" # x"#;
let hash_char_idx = line.chars().position(|c| c == '#').unwrap();
let wire_utf16 = col_to_wire(line, hash_char_idx, PositionEncoding::Utf16);
assert_eq!(
wire_to_col(line, wire_utf16, PositionEncoding::Utf16),
hash_char_idx
);
assert_eq!(&line[..line.find('#').unwrap()], "s = \"héllo\" ");
let wire_utf8 = col_to_wire(line, hash_char_idx, PositionEncoding::Utf8);
assert!(wire_utf8 > wire_utf16);
assert_eq!(
wire_to_col(line, wire_utf8, PositionEncoding::Utf8),
hash_char_idx
);
}
}
#[cfg(test)]
mod lsp_glue_tests {
use super::{build_text_changes, snap_to_char_boundary};
fn apply_change(doc: &str, change: &hjkl_lsp::TextChange) -> String {
assert_eq!(change.start_line, 0);
assert_eq!(change.end_line, 0);
let start = change.start_col as usize;
let end = change.end_col as usize;
let mut out = String::new();
out.push_str(&doc[..start]);
out.push_str(&change.text);
out.push_str(&doc[end..]);
out
}
#[test]
fn snap_floors_interior_emoji_byte() {
let rope = ropey::Rope::from_str("hi😀bye");
let emoji_start = 2usize;
for interior in emoji_start..emoji_start + 4 {
let snapped = snap_to_char_boundary(&rope, interior);
assert_eq!(
snapped, emoji_start,
"byte {interior} inside emoji should snap to byte {emoji_start}, got {snapped}"
);
}
let past = emoji_start + 4;
assert_eq!(snap_to_char_boundary(&rope, past), past);
}
#[test]
fn build_text_changes_no_panic_on_emoji() {
use hjkl_engine::ContentEdit;
let rope = ropey::Rope::from_str("abc😀\n");
let len = rope.len_bytes();
let edit = ContentEdit {
start_byte: 0,
old_end_byte: 0,
new_end_byte: len.saturating_sub(2), start_position: (0, 0),
old_end_position: (0, 0),
new_end_position: (0, 0),
};
let changes = build_text_changes(&rope, &[edit]).expect("single edit is ascending-safe");
assert!(!changes.is_empty());
assert!(std::str::from_utf8(changes[0].text.as_bytes()).is_ok());
}
#[test]
fn build_text_changes_rejects_descending_batch() {
use hjkl_engine::ContentEdit;
let pre = "foo bar foo";
let post = "x bar x";
let final_rope = ropey::Rope::from_str(post);
let edits = vec![
ContentEdit {
start_byte: 8,
old_end_byte: 11,
new_end_byte: 9,
start_position: (0, 8),
old_end_position: (0, 11),
new_end_position: (0, 9),
},
ContentEdit {
start_byte: 0,
old_end_byte: 3,
new_end_byte: 1,
start_position: (0, 0),
old_end_position: (0, 3),
new_end_position: (0, 1),
},
];
assert!(
build_text_changes(&final_rope, &edits).is_none(),
"descending batch must be rejected instead of sliced from the \
final rope — the caller must fall back to a full-document sync"
);
let _ = pre;
}
#[test]
fn build_text_changes_ascending_batch_replays_correctly() {
use hjkl_engine::ContentEdit;
let pre = "foo bar foo";
let post = "x bar y";
let final_rope = ropey::Rope::from_str(post);
let edits = vec![
ContentEdit {
start_byte: 0,
old_end_byte: 3,
new_end_byte: 1,
start_position: (0, 0),
old_end_position: (0, 3),
new_end_position: (0, 1),
},
ContentEdit {
start_byte: 6,
old_end_byte: 9,
new_end_byte: 7,
start_position: (0, 6),
old_end_position: (0, 9),
new_end_position: (0, 7),
},
];
let changes =
build_text_changes(&final_rope, &edits).expect("ascending batch must be accepted");
assert_eq!(changes.len(), 2);
let mut doc = pre.to_string();
for change in &changes {
doc = apply_change(&doc, change);
}
assert_eq!(
doc, post,
"replaying the ascending batch's changes against the pre-edit \
doc must reconstruct the post-edit doc"
);
}
#[test]
#[allow(clippy::mutable_key_type)] fn workspace_edit_outside_root_warns() {
use std::str::FromStr;
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let victim = outside.path().join("victim.txt");
std::fs::write(&victim, "hello\n").unwrap();
let mut app = super::App::new(None, false, None, None).unwrap();
app.lsp_state.insert(
hjkl_lsp::ServerKey {
language: "rust".into(),
root: root.path().to_path_buf(),
},
super::LspServerInfo {
initialized: true,
capabilities: serde_json::Value::Null,
},
);
let url = hjkl_lsp::uri::from_path(&victim).unwrap();
let uri = lsp_types::Uri::from_str(url.as_str()).unwrap();
let te = lsp_types::TextEdit {
range: lsp_types::Range::new(
lsp_types::Position::new(0, 0),
lsp_types::Position::new(0, 5),
),
new_text: "howdy".into(),
};
let mut changes = std::collections::HashMap::new();
changes.insert(uri, vec![te]);
let edit = lsp_types::WorkspaceEdit {
changes: Some(changes),
..Default::default()
};
let applied = app
.apply_workspace_edit(edit, hjkl_lsp::PositionEncoding::Utf8)
.unwrap();
assert_eq!(applied, 1, "the out-of-root edit must still be applied");
let warned = app
.bus
.history()
.any(|h| h.severity == hjkl_holler::Severity::Warn && h.body.contains("victim.txt"));
assert!(warned, "expected a WARN toast naming the out-of-root file");
}
}