use super::*;
#[test]
fn publish_diagnostics_populates_slot_diags() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_diag_test.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "let x = ();\nlet y = ();");
let params = pub_diags_params(
&file_url(&path),
serde_json::json!([{
"range": {
"start": { "line": 0, "character": 4 },
"end": { "line": 0, "character": 5 }
},
"severity": 1,
"message": "unused variable",
"source": "rustc",
"code": "E0001"
}]),
);
app.handle_publish_diagnostics(params);
let slot = app.active();
assert_eq!(slot.lsp_diags.len(), 1);
let d = &slot.lsp_diags[0];
assert_eq!(d.start_row, 0);
assert_eq!(d.start_col, 4);
assert_eq!(d.end_row, 0);
assert_eq!(d.end_col, 5);
assert_eq!(d.severity, DiagSeverity::Error);
assert_eq!(d.message, "unused variable");
assert_eq!(d.source.as_deref(), Some("rustc"));
assert_eq!(d.code.as_deref(), Some("E0001"));
assert!(
slot.diag_signs_lsp
.iter()
.any(|s| s.row == 0 && s.ch == 'E'),
"expected an 'E' gutter sign for row 0"
);
}
#[test]
fn publish_diagnostics_replaces_existing() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_diag_replace.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "a\nb\nc");
let params1 = pub_diags_params(
&file_url(&path),
serde_json::json!([
{
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } },
"severity": 1,
"message": "err A"
},
{
"range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 1 } },
"severity": 2,
"message": "warn B"
}
]),
);
app.handle_publish_diagnostics(params1);
assert_eq!(app.active().lsp_diags.len(), 2);
let params2 = pub_diags_params(
&file_url(&path),
serde_json::json!([{
"range": { "start": { "line": 2, "character": 0 }, "end": { "line": 2, "character": 1 } },
"severity": 3,
"message": "info C"
}]),
);
app.handle_publish_diagnostics(params2);
let slot = app.active();
assert_eq!(
slot.lsp_diags.len(),
1,
"second publish must replace, not append"
);
assert_eq!(slot.lsp_diags[0].message, "info C");
assert_eq!(slot.lsp_diags[0].severity, DiagSeverity::Info);
assert_eq!(slot.diag_signs_lsp.len(), 1);
assert_eq!(slot.diag_signs_lsp[0].row, 2);
}
#[test]
fn publish_diagnostics_clears_on_empty() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_diag_clear.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "a");
let params_with = pub_diags_params(
&file_url(&path),
serde_json::json!([{
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } },
"severity": 1,
"message": "err"
}]),
);
app.handle_publish_diagnostics(params_with);
assert_eq!(app.active().lsp_diags.len(), 1);
let params_clear = pub_diags_params(&file_url(&path), serde_json::json!([]));
app.handle_publish_diagnostics(params_clear);
let slot = app.active();
assert!(slot.lsp_diags.is_empty(), "empty publish must clear diags");
assert!(
slot.diag_signs_lsp.is_empty(),
"empty publish must clear gutter signs"
);
}
#[test]
fn publish_diagnostics_ignores_unknown_uri() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_diag_known.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "a");
let unknown_path = tmp_path("hjkl_diag_unknown.rs");
let params = pub_diags_params(
&file_url(&unknown_path),
serde_json::json!([{
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } },
"severity": 1,
"message": "err"
}]),
);
app.handle_publish_diagnostics(params);
assert!(
app.active().lsp_diags.is_empty(),
"unmatched URI must not populate diags"
);
}
#[test]
fn lnext_jumps_to_next_diag() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_lnext.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "a\nb\nc\nhello world");
let params = pub_diags_params(
&file_url(&path),
serde_json::json!([
{
"range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 1 } },
"severity": 1,
"message": "first"
},
{
"range": { "start": { "line": 3, "character": 6 }, "end": { "line": 3, "character": 11 } },
"severity": 2,
"message": "second"
}
]),
);
app.handle_publish_diagnostics(params);
app.lnext_severity(None);
let (row, _col) = app.active().editor.cursor();
assert_eq!(row, 1, "lnext must jump to first diag after cursor");
app.lnext_severity(None);
let (row, col) = app.active().editor.cursor();
assert_eq!(row, 3);
assert_eq!(col, 6, "lnext must place cursor at diag start_col");
}
#[test]
fn gg_scrolls_window_viewport_to_top() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.set_viewport_height(20);
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.width = 80;
vp.height = 20;
vp.text_width = 80;
vp.top_row = 60;
}
app.active_mut().editor.jump_cursor(70, 0);
app.sync_viewport_from_editor();
let fw = app.focused_window();
assert_eq!(app.windows[fw].as_ref().unwrap().top_row, 60);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
hjkl_vim::handle_key(
&mut app.active_mut().editor,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
);
app.sync_viewport_from_editor();
let (row, _col) = app.active().editor.cursor();
assert_eq!(row, 0, "gg must put cursor at row 0");
let stored_top = app.windows[fw].as_ref().unwrap().top_row;
assert!(
stored_top < 60,
"gg must scroll window viewport to top, but stored top_row stayed at {stored_top}"
);
}
#[test]
fn plus_slash_argv_scrolls_window_viewport_to_match() {
use std::io::Write;
let dir = std::env::temp_dir().join("hjkl_plus_slash_scroll");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sample.rs");
{
let mut f = std::fs::File::create(&path).unwrap();
for i in 0..100 {
if i == 80 {
writeln!(f, "fn target() {{}}").unwrap();
} else {
writeln!(f, "// padding line {i}").unwrap();
}
}
}
let mut app = App::new(Some(path.clone()), false, None, Some("target".into())).unwrap();
let (row, _col) = app.active().editor.cursor();
assert_eq!(row, 80, "+/target must move cursor to row 80");
app.active_mut().editor.set_viewport_height(20);
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.width = 80;
vp.height = 20;
vp.text_width = 80;
}
app.active_mut().editor.ensure_cursor_in_scrolloff();
let editor_top = app.active().editor.host().viewport().top_row;
assert!(
editor_top > 0,
"ensure_cursor_in_scrolloff should scroll editor viewport away from row 0; got top_row={editor_top}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn slash_search_in_editor_scrolls_window_viewport() {
let mut app = App::new(None, false, None, None).unwrap();
let lines: Vec<String> = (0..100)
.map(|i| {
if i == 80 {
"target".into()
} else {
format!("line {i}")
}
})
.collect();
seed_buffer(&mut app, &lines.join("\n"));
app.active_mut().editor.set_viewport_height(20);
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.width = 80;
vp.height = 20;
vp.text_width = 80;
}
let fw = app.focused_window();
app.commit_search("target");
let stored_top = app.windows[fw].as_ref().unwrap().top_row;
assert!(
stored_top > 0,
"/target<CR> should scroll the focused window's stored top_row past 0 to reveal the match"
);
let (row, _col) = app.active().editor.cursor();
assert_eq!(row, 80, "/target<CR> should land cursor on row 80");
let count = crate::render::search_count(&app);
assert_eq!(
count,
Some((1, 1)),
"search counter must update after /<CR>"
);
let stored_top = app.windows[fw].as_ref().unwrap().top_row;
let screen_row = 80usize.saturating_sub(stored_top);
assert!(
(5..=14).contains(&screen_row),
"scrolloff=5 violated: screen_row={screen_row} (top={stored_top}, cursor=80, height=20)"
);
}
#[test]
fn plus_slash_argv_with_realistic_rust_source() {
use std::io::Write;
let dir = std::env::temp_dir().join("hjkl_plus_slash_real");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sample.rs");
{
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "//! crate root").unwrap(); writeln!(f).unwrap(); writeln!(f, "use std::path::PathBuf;").unwrap();
writeln!(f).unwrap();
writeln!(f, "/// Entry.").unwrap();
writeln!(f, "fn main() {{").unwrap(); writeln!(f, " let _ = main_helper();").unwrap(); writeln!(f, "}}").unwrap();
writeln!(f, "fn main_helper() {{}}").unwrap(); }
let app = App::new(Some(path.clone()), false, None, Some("main".into())).unwrap();
let (row, _col) = app.active().editor.cursor();
assert_eq!(
row, 5,
"+/main on rust source must land on row 5 (first `fn main`), got row {row}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn plus_slash_argv_search_lands_on_first_forward_match() {
use std::io::Write;
let dir = std::env::temp_dir().join("hjkl_plus_slash_test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sample.txt");
{
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "alpha").unwrap();
writeln!(f, "beta").unwrap();
writeln!(f, "main one").unwrap();
writeln!(f, "delta").unwrap();
writeln!(f, "main two").unwrap();
writeln!(f, "main three").unwrap();
}
let app = App::new(Some(path.clone()), false, None, Some("main".into())).unwrap();
let (row, col) = app.active().editor.cursor();
assert_eq!(
row, 2,
"+/main must land on the FIRST forward match (row 2), got row {row}"
);
assert_eq!(col, 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn plus_slash_argv_search_with_goto_line_searches_forward() {
use std::io::Write;
let dir = std::env::temp_dir().join("hjkl_plus_slash_goto_test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sample.txt");
{
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "main early").unwrap(); writeln!(f, "two").unwrap();
writeln!(f, "three").unwrap();
writeln!(f, "four").unwrap();
writeln!(f, "five").unwrap(); writeln!(f, "six").unwrap();
writeln!(f, "main mid").unwrap(); writeln!(f, "main late").unwrap(); }
let app = App::new(Some(path.clone()), false, Some(5), Some("main".into())).unwrap();
let (row, _col) = app.active().editor.cursor();
assert_eq!(
row, 6,
"+5 +/main must search forward from row 4, landing on row 6"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn plus_slash_argv_persists_forward_direction_for_n() {
use hjkl_engine::{Input, Key};
use std::io::Write;
let dir = std::env::temp_dir().join("hjkl_plus_slash_n_dir");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sample.txt");
{
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "alpha").unwrap(); writeln!(f, "beta").unwrap(); writeln!(f, "main one").unwrap(); writeln!(f, "delta").unwrap(); writeln!(f, "main two").unwrap(); writeln!(f, "main three").unwrap(); }
let mut app = App::new(Some(path.clone()), false, None, Some("main".into())).unwrap();
let (row0, _) = app.active().editor.cursor();
assert_eq!(row0, 2, "+/main must land on first match (row 2)");
assert_eq!(app.active().editor.last_search(), Some("main"));
let n_input = Input {
key: Key::Char('n'),
..Default::default()
};
hjkl_vim::dispatch_input(&mut app.active_mut().editor, n_input);
let (row1, _) = app.active().editor.cursor();
assert_eq!(
row1, 4,
"after +/main, `n` must advance FORWARD to row 4 (got row {row1}); \
backward would land on row 0 (no match) or stay/regress"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn search_count_cursor_on_match_stays_on_match() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "foo X foo X foo");
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 5;
vp.top_row = 0;
}
app.commit_search("foo");
assert_eq!(
crate::render::search_count(&app),
Some((1, 3)),
"/<pat><CR> from cursor on a match must keep counter at 1/3, \
not advance to 2/3"
);
}
#[test]
fn search_count_n_press_increments_by_one() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "X foo X foo X foo");
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 5;
vp.top_row = 0;
}
app.commit_search("foo");
assert_eq!(crate::render::search_count(&app), Some((1, 3)));
app.active_mut().editor.search_advance_forward(true);
assert_eq!(
crate::render::search_count(&app),
Some((2, 3)),
"n must advance counter from 1/3 to 2/3, not skip"
);
app.active_mut().editor.search_advance_forward(true);
assert_eq!(crate::render::search_count(&app), Some((3, 3)));
}
#[test]
fn search_count_handles_multibyte_chars_before_match() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "alpha\n/// — main one\nbeta\nmain two");
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 10;
vp.top_row = 0;
}
app.commit_search("main");
assert_eq!(
crate::render::search_count(&app),
Some((1, 2)),
"/main must land on M1 with counter 1/2, even when M1 sits \
behind a multi-byte char (em-dash) on its line"
);
app.active_mut().editor.search_advance_forward(true);
assert_eq!(crate::render::search_count(&app), Some((2, 2)));
}
#[test]
fn search_count_through_full_key_flow() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "X foo X foo X foo");
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 5;
vp.top_row = 0;
}
app.open_search_prompt(crate::app::SearchDir::Forward);
for ch in ['f', 'o', 'o'] {
let key = KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE);
app.handle_search_field_key(key);
}
let count = crate::render::search_count(&app);
assert_eq!(count, Some((0, 3)), "during typing, counter must be 0/3");
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
app.handle_search_field_key(enter);
let count = crate::render::search_count(&app);
assert_eq!(
count,
Some((1, 3)),
"after / submit, counter must be 1/3 — bug was 2/3"
);
}
#[test]
fn search_count_after_commit_lands_on_first_match() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "X foo X foo X foo");
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 5;
vp.top_row = 0;
}
app.commit_search("foo");
let count = crate::render::search_count(&app);
assert_eq!(
count,
Some((1, 3)),
"/{{pat}}<CR> from a non-match cursor must land on match 1, not skip to 2"
);
}
#[test]
fn lsp_jump_reveals_cursor_in_viewport() {
use crate::app::window::WindowId;
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_jump_scroll.rs");
app.active_mut().filename = Some(path.clone());
let lines: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
seed_buffer(&mut app, &lines.join("\n"));
{
let vp = app.active_mut().editor.host_mut().viewport_mut();
vp.height = 20;
vp.top_row = 0;
}
let fw: WindowId = app.focused_window();
if let Some(w) = app.windows[fw].as_mut() {
w.top_row = 0;
}
let params = pub_diags_params(
&file_url(&path),
serde_json::json!([{
"range": { "start": { "line": 50, "character": 0 }, "end": { "line": 50, "character": 1 } },
"severity": 1,
"message": "deep"
}]),
);
app.handle_publish_diagnostics(params);
app.lnext_severity(None);
let (row, _) = app.active().editor.cursor();
assert_eq!(row, 50);
let vp_top = app.active().editor.host().viewport().top_row;
assert!(
vp_top > 0,
"viewport top_row stayed at 0 after jump — ensure_cursor_in_scrolloff not called"
);
let stored_top = app.windows[fw].as_ref().unwrap().top_row;
assert!(
stored_top > 0,
"focused window's stored top_row stayed at 0 — sync_viewport_from_editor missed the scroll"
);
}
#[test]
fn lprev_jumps_to_prev_diag_with_wrap() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_lprev.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "a\nb\nc\nd");
let params = pub_diags_params(
&file_url(&path),
serde_json::json!([
{
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } },
"severity": 1,
"message": "first"
},
{
"range": { "start": { "line": 2, "character": 1 }, "end": { "line": 2, "character": 2 } },
"severity": 2,
"message": "second"
}
]),
);
app.handle_publish_diagnostics(params);
app.lprev_severity(None);
let (row, _) = app.active().editor.cursor();
assert_eq!(row, 2, "lprev from first diag must wrap to last");
app.lprev_severity(None);
let (row, _) = app.active().editor.cursor();
assert_eq!(row, 0, "lprev must jump to previous diag");
}
#[test]
fn lnext_severity_skips_lower_severity() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_lnext_sev.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "a\nb\nc");
let params = pub_diags_params(
&file_url(&path),
serde_json::json!([
{
"range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 1 } },
"severity": 2,
"message": "warn"
},
{
"range": { "start": { "line": 2, "character": 0 }, "end": { "line": 2, "character": 1 } },
"severity": 1,
"message": "err"
}
]),
);
app.handle_publish_diagnostics(params);
app.lnext_severity(Some(DiagSeverity::Error));
let (row, _) = app.active().editor.cursor();
assert_eq!(row, 2, "lnext with Error filter must skip Warning diags");
}
#[test]
fn lopen_shows_no_diags_message_when_empty() {
let mut app = App::new(None, false, None, None).unwrap();
app.open_diag_picker();
assert!(app.picker.is_none(), "picker must not open when no diags");
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("no diagnostics"),
"expected 'no diagnostics', got: {msg}"
);
}
#[test]
fn lopen_lists_diags_in_picker() {
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_lopen.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "a\nb");
let params = pub_diags_params(
&file_url(&path),
serde_json::json!([{
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } },
"severity": 1,
"message": "some error"
}]),
);
app.handle_publish_diagnostics(params);
app.open_diag_picker();
assert!(app.picker.is_some(), "picker must open when diags exist");
}
#[test]
fn lsp_info_with_lsp_disabled_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
app.show_lsp_info();
let popup_content = app
.info_popup
.as_ref()
.map(|p| p.content.as_str())
.unwrap_or_default();
assert!(
popup_content.contains("LSP: disabled"),
"expected 'LSP: disabled' message, got: {popup_content}"
);
}
#[test]
fn lsp_info_lists_running_servers() {
let mut app = App::new(None, false, None, None).unwrap();
app.lsp = Some(hjkl_lsp::LspManager::spawn(hjkl_lsp::LspConfig::default()));
let key = hjkl_lsp::ServerKey {
language: "rust".into(),
root: std::path::PathBuf::from("/tmp/proj"),
};
app.lsp_state.insert(
key,
LspServerInfo {
initialized: true,
capabilities: serde_json::json!({}),
},
);
app.show_lsp_info();
assert!(
app.info_popup.is_some(),
"popup must open when LSP is enabled"
);
let popup = app.info_popup.as_ref().unwrap();
assert!(
popup.content.contains("rust"),
"popup must mention server language"
);
assert!(
popup.content.contains("initialized"),
"popup must show server state"
);
if let Some(mgr) = app.lsp.take() {
mgr.shutdown();
}
}
#[test]
fn notify_change_skipped_when_dirty_gen_unchanged() {
let mut app = App::new(None, false, None, None).unwrap();
let dg = app.active().editor.buffer().dirty_gen();
app.active_mut().last_lsp_dirty_gen = Some(dg);
app.lsp_notify_change_active();
assert_eq!(
app.active().last_lsp_dirty_gen,
Some(dg),
"guard must remain unchanged when no LSP manager"
);
}
#[test]
fn goto_definition_single_jumps_cursor() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "line0\nline1\nline2\nline3");
let path = tmp_path("hjkl_gd_single.rs");
app.active_mut().filename = Some(path.clone());
let uri = file_url(&path);
let loc = make_location(&uri, 2, 0);
let result = ok_val(serde_json::to_value(vec![loc]).unwrap());
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
app.handle_goto_response(buffer_id, (0, 0), result, "definition");
assert_eq!(app.active().editor.buffer().cursor().row, 2);
assert!(app.picker.is_none(), "single result must not open picker");
}
#[test]
fn goto_definition_empty_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
let result = ok_val(serde_json::Value::Null);
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
app.handle_goto_response(buffer_id, (0, 0), result, "definition");
let msg = app.bus.last_body_or_empty();
assert!(
msg.contains("no definition found"),
"expected 'no definition found', got: {msg}"
);
assert!(app.picker.is_none());
}
#[test]
fn goto_definition_multi_opens_picker() {
let mut app = App::new(None, false, None, None).unwrap();
let locs = vec![
make_location(&file_url(&tmp_path("hjkl_gd_multi_a.rs")), 0, 0),
make_location(&file_url(&tmp_path("hjkl_gd_multi_b.rs")), 5, 3),
make_location(&file_url(&tmp_path("hjkl_gd_multi_c.rs")), 10, 1),
];
let result = ok_val(serde_json::to_value(locs).unwrap());
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
app.handle_goto_response(buffer_id, (0, 0), result, "definition");
assert!(app.picker.is_some(), "multiple results must open picker");
}
#[test]
fn goto_references_always_opens_picker() {
let mut app = App::new(None, false, None, None).unwrap();
let locs = vec![make_location(&file_url(&tmp_path("hjkl_gd_only.rs")), 3, 0)];
let result = ok_val(serde_json::to_value(locs).unwrap());
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
app.handle_references_response(buffer_id, (0, 0), result);
assert!(app.picker.is_some(), "references must always open picker");
}
#[test]
fn hover_response_sets_info_popup() {
let mut app = App::new(None, false, None, None).unwrap();
let hover = lsp_types::Hover {
contents: lsp_types::HoverContents::Markup(lsp_types::MarkupContent {
kind: lsp_types::MarkupKind::Markdown,
value: "**fn** foo() -> i32".to_string(),
}),
range: None,
};
let result = ok_val(serde_json::to_value(hover).unwrap());
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
app.handle_hover_response(buffer_id, (0, 0), result);
assert!(app.info_popup.is_some(), "hover must set info_popup");
let popup = app.info_popup.as_ref().unwrap();
assert!(
popup.content.contains("foo"),
"popup must contain function name"
);
assert_eq!(popup.kind, hjkl_info_popup::ContentKind::Markdown);
}
#[test]
fn hover_empty_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
let result: Result<serde_json::Value, hjkl_lsp::RpcError> = Ok(serde_json::Value::Null);
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
app.handle_hover_response(buffer_id, (0, 0), result);
let msg = app.bus.last_body_or_empty();
assert!(
msg.contains("no hover info"),
"expected 'no hover info', got: {msg}"
);
assert!(app.info_popup.is_none());
}
#[test]
fn goto_definition_error_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
let result = err_val("server error");
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
app.handle_goto_response(buffer_id, (0, 0), result, "definition");
let msg = app.bus.last_body_or_empty();
assert!(
msg.contains("server error"),
"expected error message, got: {msg}"
);
}
#[test]
fn k_dispatches_hover() {
let mut app = App::new(None, false, None, None).unwrap();
app.active_mut().filename = Some(tmp_path("k_test.rs"));
app.lsp_hover();
assert!(app.info_popup.is_none());
let msg = app.bus.last_body_or_empty();
assert!(msg.contains("LSP: not enabled"), "got: {msg}");
}
#[test]
fn gd_dispatches_goto_definition() {
let mut app = App::new(None, false, None, None).unwrap();
app.active_mut().filename = Some(tmp_path("gd_test.rs"));
app.lsp_goto_definition();
assert!(app.lsp_pending.is_empty());
}
#[test]
fn lsp_request_works_with_relative_filename() {
let mut app = App::new(None, false, None, None).unwrap();
let mgr = hjkl_lsp::LspManager::spawn(hjkl_lsp::LspConfig::default());
app.lsp = Some(mgr);
app.active_mut().filename = Some(std::path::PathBuf::from("src/main.rs"));
app.lsp_goto_definition();
assert_eq!(
app.lsp_pending.len(),
1,
"relative-path goto must produce a pending request, not the \
'no file open' error path"
);
if let Some(mgr) = app.lsp.take() {
mgr.shutdown();
}
}
#[test]
fn completion_response_opens_popup() {
let mut app = App::new(None, false, None, None).unwrap();
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('i')));
app.active_mut().filename = Some(std::path::PathBuf::from("/tmp/test.rs"));
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
let response_val = synthesize_completion_response(&["foo", "bar", "baz"]);
app.handle_completion_response(buffer_id, 0, 0, Ok(response_val));
assert!(app.completion.is_some(), "popup should open");
let popup = app.completion.as_ref().unwrap();
assert_eq!(popup.all_items.len(), 3);
assert_eq!(popup.visible.len(), 3);
}
#[test]
fn completion_response_empty_no_popup() {
let mut app = App::new(None, false, None, None).unwrap();
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('i')));
app.active_mut().filename = Some(std::path::PathBuf::from("/tmp/test.rs"));
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
let response_val = serde_json::json!([]);
app.handle_completion_response(buffer_id, 0, 0, Ok(response_val));
assert!(
app.completion.is_none(),
"empty response must not open popup"
);
assert!(
app.bus.last_body_or_empty().contains("no completions"),
"status should report no completions"
);
}
#[test]
fn completion_request_pending_routes_to_handler() {
let mut app = App::new(None, false, None, None).unwrap();
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('i')));
app.active_mut().filename = Some(std::path::PathBuf::from("/tmp/test.rs"));
let buffer_id = app.active().buffer_id as hjkl_lsp::BufferId;
let req_id = app.lsp_alloc_request_id();
app.lsp_pending.insert(
req_id,
LspPendingRequest::Completion {
buffer_id,
anchor_row: 0,
anchor_col: 0,
},
);
let response_val = synthesize_completion_response(&["alpha", "beta"]);
let pending = app.lsp_pending.remove(&req_id).unwrap();
app.handle_lsp_response(pending, Ok(response_val));
assert!(
app.completion.is_some(),
"response must route to popup opener"
);
let popup = app.completion.as_ref().unwrap();
assert_eq!(popup.all_items.len(), 2);
}
#[test]
fn accept_completion_inserts_selected_item() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "fn foo");
hjkl_vim::handle_key(&mut app.active_mut().editor, key(KeyCode::Char('i')));
let items = vec![make_completion_item("hello"), make_completion_item("world")];
app.completion = Some(crate::completion::Completion::new(0, 0, items));
app.completion.as_mut().unwrap().selected = 1;
app.accept_completion();
app.sync_after_engine_mutation();
assert!(app.completion.is_none());
let line = app.active().editor.buffer().lines()[0].clone();
assert!(
line.starts_with("world"),
"buffer line should start with inserted text, got: {line:?}"
);
assert!(
!app.active_mut().editor.take_dirty(),
"accept_completion call site must drain dirty via sync_after_engine_mutation"
);
assert!(
app.active_mut().editor.take_content_edits().is_empty(),
"accept_completion call site must drain content_edits"
);
}
#[test]
fn dismiss_completion_clears_state() {
let mut app = App::new(None, false, None, None).unwrap();
let items = vec![make_completion_item("foo")];
app.completion = Some(crate::completion::Completion::new(0, 0, items));
app.pending_ctrl_x = true;
app.dismiss_completion();
assert!(app.completion.is_none());
assert!(!app.pending_ctrl_x);
}
#[test]
fn set_prefix_dismisses_when_filter_empty() {
let items = vec![make_completion_item("alpha"), make_completion_item("beta")];
let mut popup = crate::completion::Completion::new(0, 0, items);
popup.set_prefix("xyz");
assert!(
popup.is_empty(),
"popup should be empty after non-matching prefix"
);
}
#[test]
#[allow(clippy::mutable_key_type)]
fn apply_workspace_edit_single_file() {
let path = std::env::temp_dir().join("hjkl_ws_edit_single.txt");
std::fs::write(&path, "hello world\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
let uri = file_url(&path);
let edit = make_workspace_edit(&uri, 0, 6, 0, 11, "rust");
let count = app
.apply_workspace_edit(edit)
.expect("apply_workspace_edit failed");
assert_eq!(count, 1);
let lines = app.active().editor.buffer().lines();
assert_eq!(
lines[0], "hello rust",
"edit should replace 'world' with 'rust'"
);
let _ = std::fs::remove_file(&path);
}
#[test]
#[allow(clippy::mutable_key_type)]
fn apply_workspace_edit_sorts_edits_descending() {
let path = std::env::temp_dir().join("hjkl_ws_edit_sort.txt");
std::fs::write(&path, "hello world foo\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
let url = file_url(&path)
.parse::<lsp_types::Uri>()
.expect("valid URI");
let mut changes = std::collections::HashMap::new();
changes.insert(
url,
vec![
lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 0,
},
end: lsp_types::Position {
line: 0,
character: 5,
},
},
new_text: "hi".to_string(),
},
lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 6,
},
end: lsp_types::Position {
line: 0,
character: 11,
},
},
new_text: "earth".to_string(),
},
],
);
let edit = lsp_types::WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
};
app.apply_workspace_edit(edit).expect("apply failed");
let lines = app.active().editor.buffer().lines();
assert_eq!(lines[0], "hi earth foo", "both edits must apply correctly");
let _ = std::fs::remove_file(&path);
}
#[test]
#[allow(clippy::mutable_key_type)]
fn apply_workspace_edit_multi_file() {
let path_a = std::env::temp_dir().join("hjkl_ws_multi_a.txt");
let path_b = std::env::temp_dir().join("hjkl_ws_multi_b.txt");
std::fs::write(&path_a, "file a content\n").unwrap();
std::fs::write(&path_b, "file b content\n").unwrap();
let mut app = App::new(Some(path_a.clone()), false, None, None).unwrap();
let uri_a = file_url(&path_a);
let uri_b = file_url(&path_b);
let url_a = uri_a.parse::<lsp_types::Uri>().expect("valid URI a");
let url_b = uri_b.parse::<lsp_types::Uri>().expect("valid URI b");
let mut changes = std::collections::HashMap::new();
changes.insert(
url_a,
vec![lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 7,
},
end: lsp_types::Position {
line: 0,
character: 14,
},
},
new_text: "edited".to_string(),
}],
);
changes.insert(
url_b,
vec![lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 7,
},
end: lsp_types::Position {
line: 0,
character: 14,
},
},
new_text: "changed".to_string(),
}],
);
let edit = lsp_types::WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
};
let count = app
.apply_workspace_edit(edit)
.expect("multi-file apply failed");
assert_eq!(count, 2, "should affect 2 files");
let _ = std::fs::remove_file(&path_a);
let _ = std::fs::remove_file(&path_b);
}
#[test]
fn rename_response_null_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
let pending = LspPendingRequest::Rename {
buffer_id: 0,
anchor_row: 0,
anchor_col: 0,
new_name: "newName".to_string(),
};
app.handle_lsp_response(pending, Ok(serde_json::Value::Null));
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("cannot rename"),
"null rename must set 'cannot rename' status, got: {msg}"
);
}
#[test]
fn rename_response_applies_workspace_edit() {
let path = std::env::temp_dir().join("hjkl_rename_apply.txt");
std::fs::write(&path, "old_name here\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
let uri = file_url(&path);
let edit = make_workspace_edit(&uri, 0, 0, 0, 8, "new_name");
let val = serde_json::to_value(edit).unwrap();
let pending = LspPendingRequest::Rename {
buffer_id: 0,
anchor_row: 0,
anchor_col: 0,
new_name: "new_name".to_string(),
};
app.handle_lsp_response(pending, Ok(val));
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("renamed"),
"rename response must set status, got: {msg}"
);
let lines = app.active().editor.buffer().lines();
assert_eq!(lines[0], "new_name here");
let _ = std::fs::remove_file(&path);
}
#[test]
fn format_response_empty_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
let pending = LspPendingRequest::Format {
buffer_id: 0,
range: None,
};
app.handle_lsp_response(pending, Ok(serde_json::json!([])));
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("no formatting"),
"empty format response must say 'no formatting changes', got: {msg}"
);
}
#[test]
fn format_response_applies_text_edits() {
let path = std::env::temp_dir().join("hjkl_format_apply.txt");
std::fs::write(&path, "fn foo(){}\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
let buf_id = app.active().buffer_id as hjkl_lsp::BufferId;
let edits: Vec<lsp_types::TextEdit> = vec![lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 9,
},
end: lsp_types::Position {
line: 0,
character: 9,
},
},
new_text: " ".to_string(),
}];
let val = serde_json::to_value(&edits).unwrap();
let pending = LspPendingRequest::Format {
buffer_id: buf_id,
range: None,
};
app.handle_lsp_response(pending, Ok(val));
let msg = app.bus.last_body_or_empty().to_string();
assert_eq!(msg, "formatted");
let lines = app.active().editor.buffer().lines();
assert_eq!(lines[0], "fn foo(){ }");
let _ = std::fs::remove_file(&path);
}
#[test]
fn code_action_response_empty_sets_status() {
let mut app = App::new(None, false, None, None).unwrap();
let pending = LspPendingRequest::CodeAction {
buffer_id: 0,
anchor_row: 0,
anchor_col: 0,
};
app.handle_lsp_response(pending, Ok(serde_json::json!([])));
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("no code actions"),
"empty code actions must say 'no code actions', got: {msg}"
);
}
#[test]
fn code_action_response_multi_opens_picker() {
let mut app = App::new(None, false, None, None).unwrap();
let pending = LspPendingRequest::CodeAction {
buffer_id: 0,
anchor_row: 0,
anchor_col: 0,
};
let actions = serde_json::json!([
{
"title": "Fix import",
"kind": "quickfix",
},
{
"title": "Extract method",
"kind": "refactor",
},
]);
app.handle_lsp_response(pending, Ok(actions));
assert!(
app.picker.is_some(),
"multiple code actions must open picker"
);
assert_eq!(
app.pending_code_actions.len(),
2,
"pending_code_actions must hold both actions"
);
}
#[test]
fn code_action_response_single_applies_action() {
let path = std::env::temp_dir().join("hjkl_ca_single.txt");
std::fs::write(&path, "old content\n").unwrap();
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
let uri = file_url(&path);
let edit = make_workspace_edit(&uri, 0, 0, 0, 11, "new content");
let action = lsp_types::CodeAction {
title: "Replace content".to_string(),
edit: Some(edit),
..Default::default()
};
let val =
serde_json::to_value(vec![lsp_types::CodeActionOrCommand::CodeAction(action)]).unwrap();
let pending = LspPendingRequest::CodeAction {
buffer_id: 0,
anchor_row: 0,
anchor_col: 0,
};
app.handle_lsp_response(pending, Ok(val));
assert!(
app.picker.is_none(),
"single code action must not open picker"
);
let msg = app.bus.last_body_or_empty().to_string();
assert!(
msg.contains("files changed"),
"single action apply must set status, got: {msg}"
);
let lines = app.active().editor.buffer().lines();
assert_eq!(lines[0], "new content");
let _ = std::fs::remove_file(&path);
}
#[test]
fn lsp_code_actions_includes_overlapping_diags_in_context() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "fn foo() {\n let x = 1;\n}\n");
app.active_mut().lsp_diags = vec![
LspDiag {
start_row: 0,
start_col: 3,
end_row: 0,
end_col: 6,
severity: DiagSeverity::Error,
message: "overlapping".to_string(),
source: None,
code: None,
},
LspDiag {
start_row: 1,
start_col: 0,
end_row: 1,
end_col: 5,
severity: DiagSeverity::Warning,
message: "not overlapping".to_string(),
source: None,
code: None,
},
];
app.active_mut().editor.jump_cursor(0, 4);
let cursor_row = 0usize;
let cursor_col = 4usize;
let diags = &app.active().lsp_diags;
let overlapping: Vec<_> = 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)
})
.collect();
assert_eq!(
overlapping.len(),
1,
"only the overlapping diag should be included"
);
assert_eq!(overlapping[0].message, "overlapping");
}
#[test]
fn lnext_then_j_preserves_diag_col() {
use hjkl_engine::{Input, Key};
let mut app = App::new(None, false, None, None).unwrap();
let path = tmp_path("hjkl_lnext_sticky.rs");
app.active_mut().filename = Some(path.clone());
seed_buffer(&mut app, "hello\nworld\nabcde fghij\n0123456789\n");
let params = pub_diags_params(
&file_url(&path),
serde_json::json!([{
"range": {
"start": { "line": 2, "character": 5 },
"end": { "line": 2, "character": 10 }
},
"severity": 1,
"message": "sticky col test diag"
}]),
);
app.handle_publish_diagnostics(params);
app.lnext_severity(None);
let (row, col) = app.active().editor.cursor();
assert_eq!(row, 2, "lnext must jump to row 2");
assert_eq!(col, 5, "lnext must place cursor at diag col 5");
assert_eq!(
app.active().editor.sticky_col(),
Some(5),
"lnext must reset sticky_col to 5 via jump_cursor"
);
hjkl_vim::dispatch_input(
&mut app.active_mut().editor,
Input {
key: Key::Char('j'),
ctrl: false,
alt: false,
shift: false,
},
);
let (row2, col2) = app.active().editor.cursor();
assert_eq!(row2, 3, "j must move to row 3");
assert_eq!(
col2, 5,
"j after lnext must aim for col 5 (set by jump_cursor); got col {col2} — \
sticky_col may not have been reset by lnext_severity"
);
}