use super::*;
use hjkl_ex::QfCommand;
use hjkl_quickfix::{QfEntry, QfKind};
fn entry(path: &std::path::Path, row: usize) -> QfEntry {
QfEntry {
path: path.to_path_buf(),
row,
col: 0,
kind: QfKind::Grep,
message: format!("hit at {row}"),
}
}
#[test]
fn quickfix_popup_nav_and_toggle() {
let mut app = App::new(None, false, None, None).unwrap();
let p = std::path::PathBuf::from("x.rs");
app.quickfix
.set(vec![entry(&p, 0), entry(&p, 1), entry(&p, 2)]);
app.handle_quickfix_command(QfCommand::Open);
assert!(app.quickfix_open);
app.quickfix_popup_down();
assert_eq!(app.quickfix.cursor(), 1);
app.quickfix_popup_down();
assert_eq!(app.quickfix.cursor(), 2);
app.quickfix_popup_up();
assert_eq!(app.quickfix.cursor(), 1);
app.handle_quickfix_command(QfCommand::Close);
assert!(!app.quickfix_open);
}
#[cfg(unix)]
#[test]
fn quickfix_make_parses_output_into_list() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("hjkl_make_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("fakemake.sh");
std::fs::write(
&script,
"#!/bin/sh\necho 'src/main.rs:3:5: error: boom' 1>&2\nexit 1\n",
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.active_editor_mut().settings_mut().makeprg = script.to_string_lossy().into_owned();
app.handle_quickfix_command(QfCommand::Make(String::new()));
assert_eq!(app.quickfix.len(), 1, ":make should populate the list");
assert!(app.quickfix_open, ":make with errors opens the popup");
let e = app.quickfix.current().unwrap();
assert_eq!((e.row, e.col), (2, 4)); assert_eq!(e.kind, QfKind::Error);
assert_eq!(e.message, "boom");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn quickfix_open_empty_does_not_show() {
let mut app = App::new(None, false, None, None).unwrap();
app.handle_quickfix_command(QfCommand::Open);
assert!(
!app.quickfix_open,
"empty quickfix list must not open the popup"
);
}
#[test]
fn quickfix_next_jumps_to_entry() {
let dir = std::env::temp_dir().join(format!("hjkl_qf_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("sample.txt");
std::fs::write(&file, "line zero\nline one\nline two\n").unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.quickfix.set(vec![entry(&file, 0), entry(&file, 2)]);
app.handle_quickfix_command(QfCommand::Next);
assert_eq!(app.quickfix.cursor(), 1);
assert_eq!(
app.active_editor().cursor().0,
2,
"cnext should jump the editor cursor to the entry's row"
);
app.dispatch_action(crate::keymap_actions::AppAction::QuickfixPrev, 1);
assert_eq!(app.quickfix.cursor(), 0);
assert_eq!(app.active_editor().cursor().0, 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn loclist_independent_from_quickfix() {
let mut app = App::new(None, false, None, None).unwrap();
let p = std::path::PathBuf::from("x.rs");
app.loclist.set(vec![entry(&p, 0), entry(&p, 1)]);
app.handle_loclist_command(QfCommand::Open);
assert!(app.loclist_open);
assert!(!app.quickfix_open);
app.loclist_popup_down();
assert_eq!(app.loclist.cursor(), 1);
assert_eq!(app.quickfix.cursor(), 0, "quickfix list untouched");
app.handle_loclist_command(QfCommand::Close);
assert!(!app.loclist_open);
}
#[test]
fn loclist_next_jumps_and_dispatch_routes() {
let dir = std::env::temp_dir().join(format!("hjkl_ll_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("sample.txt");
std::fs::write(&file, "line zero\nline one\nline two\n").unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.loclist.set(vec![entry(&file, 0), entry(&file, 2)]);
app.handle_loclist_command(QfCommand::Next);
assert_eq!(app.loclist.cursor(), 1);
assert_eq!(app.active_editor().cursor().0, 2);
app.dispatch_action(crate::keymap_actions::AppAction::LoclistPrev, 1);
assert_eq!(app.loclist.cursor(), 0);
assert_eq!(app.active_editor().cursor().0, 0);
let _ = std::fs::remove_dir_all(&dir);
}
fn make_app_with_buffer() -> App {
let mut app = App::new(None, false, None, None).unwrap();
let content = "line1\nline2\nline3\nline4\nline5\nline6\n";
app.active_editor_mut().set_content(content);
app.active_editor_mut().settings_mut().errorformat = "%l:%c:%m".to_string();
app
}
#[test]
fn cexpr_populates_list_and_jumps_to_first() {
let mut app = make_app_with_buffer();
app.handle_quickfix_command(QfCommand::Expr {
text: r#""3:2:three\n5:1:five""#.to_string(),
append: false,
jump: true,
});
assert_eq!(app.quickfix.len(), 2, "should have 2 entries");
assert_eq!(app.quickfix.cursor(), 0);
let e0 = app.quickfix.current().unwrap();
assert_eq!(e0.row, 2, "entry 0 row should be 2 (0-based)");
assert_eq!(e0.col, 1, "entry 0 col should be 1 (0-based)");
assert_eq!(
app.active_editor().cursor().0,
2,
"editor row should be at first entry (row 2)"
);
assert_eq!(
app.active_editor().cursor().1,
1,
"editor col should be at first entry (col 1)"
);
}
#[test]
fn cexpr_then_cnext_moves_to_second_entry() {
let mut app = make_app_with_buffer();
app.handle_quickfix_command(QfCommand::Expr {
text: r#""3:2:three\n5:1:five""#.to_string(),
append: false,
jump: true,
});
app.handle_quickfix_command(QfCommand::Next);
assert_eq!(app.quickfix.cursor(), 1);
let e1 = app.quickfix.current().unwrap();
assert_eq!(e1.row, 4, "entry 1 row should be 4 (0-based from line 5)");
assert_eq!(e1.col, 0, "entry 1 col should be 0 (0-based from col 1)");
assert_eq!(
app.active_editor().cursor().0,
4,
"editor row should be at second entry (row 4)"
);
}
#[test]
fn cgetexpr_populates_but_does_not_jump() {
let mut app = make_app_with_buffer();
let initial_row = app.active_editor().cursor().0;
app.handle_quickfix_command(QfCommand::Expr {
text: r#""3:2:three""#.to_string(),
append: false,
jump: false,
});
assert_eq!(app.quickfix.len(), 1, "list should have 1 entry");
assert_eq!(
app.active_editor().cursor().0,
initial_row,
"cgetexpr must not move cursor"
);
}
#[test]
fn caddexpr_appends_to_existing_list() {
let mut app = make_app_with_buffer();
app.handle_quickfix_command(QfCommand::Expr {
text: r#""2:0:two""#.to_string(),
append: false,
jump: false,
});
assert_eq!(app.quickfix.len(), 1);
app.handle_quickfix_command(QfCommand::Expr {
text: r#""4:1:four""#.to_string(),
append: true,
jump: false,
});
assert_eq!(app.quickfix.len(), 2, "caddexpr should append to list");
assert_eq!(app.quickfix.cursor(), 0);
}
fn make_app_with_cbuffer_content() -> App {
let mut app = App::new(None, false, None, None).unwrap();
app.active_editor_mut().set_content("1:1:a\n2:1:b\n3:1:c\n");
app.active_editor_mut().settings_mut().errorformat = "%l:%c:%m".to_string();
app
}
#[test]
fn cbuffer_populates_list_and_jumps_to_first() {
let mut app = make_app_with_cbuffer_content();
app.handle_quickfix_command(QfCommand::FromBuffer {
append: false,
jump: true,
});
assert_eq!(app.quickfix.len(), 3, "cbuffer should produce 3 entries");
assert_eq!(app.quickfix.cursor(), 0);
let e0 = app.quickfix.current().unwrap();
assert_eq!(e0.row, 0, "first entry row should be 0");
assert_eq!(e0.col, 0, "first entry col should be 0");
assert_eq!(
app.active_editor().cursor().0,
0,
"editor row should be at first entry (row 0)"
);
}
#[test]
fn cbuffer_then_cnext_moves_to_second_entry() {
let mut app = make_app_with_cbuffer_content();
app.handle_quickfix_command(QfCommand::FromBuffer {
append: false,
jump: true,
});
app.handle_quickfix_command(QfCommand::Next);
assert_eq!(app.quickfix.cursor(), 1);
let e1 = app.quickfix.current().unwrap();
assert_eq!(e1.row, 1, "second entry row should be 1");
assert_eq!(
app.active_editor().cursor().0,
1,
"editor row should be at second entry (row 1)"
);
}
#[test]
fn cgetbuffer_populates_but_does_not_jump() {
let mut app = make_app_with_cbuffer_content();
let initial_row = app.active_editor().cursor().0;
app.handle_quickfix_command(QfCommand::FromBuffer {
append: false,
jump: false,
});
assert_eq!(app.quickfix.len(), 3, "cgetbuffer should produce 3 entries");
assert_eq!(
app.active_editor().cursor().0,
initial_row,
"cgetbuffer must not move cursor"
);
}
#[test]
fn caddbuffer_appends_to_existing_list() {
let mut app = make_app_with_cbuffer_content();
app.handle_quickfix_command(QfCommand::FromBuffer {
append: false,
jump: false,
});
assert_eq!(app.quickfix.len(), 3);
app.active_editor_mut().set_content("4:1:d\n5:1:e\n");
app.handle_quickfix_command(QfCommand::FromBuffer {
append: true,
jump: false,
});
assert_eq!(
app.quickfix.len(),
5,
"caddbuffer should append to existing list"
);
assert_eq!(app.quickfix.cursor(), 0);
}
#[test]
fn cfile_populates_list_from_disk_and_jumps() {
let dir = std::env::temp_dir().join(format!("hjkl_cfile_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let errfile = dir.join("errors.txt");
let errfile_path = errfile.to_str().unwrap().to_string();
std::fs::write(&errfile, "1:1:alpha\n2:1:beta\n3:1:gamma\n").unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.active_editor_mut().settings_mut().errorformat = "%l:%c:%m".to_string();
app.handle_quickfix_command(QfCommand::FromFile {
path: errfile_path,
append: false,
jump: true,
});
assert_eq!(app.quickfix.len(), 3, "cfile should produce 3 entries");
let e0 = app.quickfix.current().unwrap();
assert_eq!(e0.row, 0, "first entry row should be 0");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cfile_missing_path_reports_error() {
let mut app = App::new(None, false, None, None).unwrap();
app.active_editor_mut().settings_mut().errorformat = "%l:%c:%m".to_string();
app.handle_quickfix_command(QfCommand::FromFile {
path: "/nonexistent/path/errors.err".to_string(),
append: false,
jump: false,
});
assert_eq!(
app.quickfix.len(),
0,
"cfile on missing file leaves list empty"
);
}
fn buf_row(app: &App, row: usize) -> String {
hjkl_buffer::rope_line_str(&app.active_editor().buffer().rope(), row)
}
#[test]
fn cdo_runs_command_per_entry() {
let mut app = App::new(None, false, None, None).unwrap();
app.active_editor_mut()
.set_content("alpha\nbeta\ngamma\ndelta\nepsilon\n");
app.active_editor_mut().settings_mut().errorformat = "%l:%c:%m".to_string();
app.handle_quickfix_command(QfCommand::Expr {
text: r#""2:1:a\n4:1:b""#.to_string(),
append: false,
jump: false,
});
assert_eq!(app.quickfix.len(), 2, "should have 2 entries");
app.handle_quickfix_command(QfCommand::Do {
cmd: "s/^/X/".to_string(),
per_file: false,
});
assert_eq!(buf_row(&app, 0), "alpha", "row 0 must be untouched");
assert!(
buf_row(&app, 1).starts_with('X'),
"row 1 must start with X, got: {:?}",
buf_row(&app, 1)
);
assert_eq!(buf_row(&app, 2), "gamma", "row 2 must be untouched");
assert!(
buf_row(&app, 3).starts_with('X'),
"row 3 must start with X, got: {:?}",
buf_row(&app, 3)
);
assert_eq!(buf_row(&app, 4), "epsilon", "row 4 must be untouched");
}
#[test]
fn cfdo_runs_once_per_file() {
let dir = std::env::temp_dir().join(format!("hjkl_cfdo_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file_a = dir.join("a.txt");
let file_b = dir.join("b.txt");
std::fs::write(&file_a, "alpha\nbeta\ngamma\ndelta\n").unwrap();
std::fs::write(&file_b, "uno\ndos\ntres\n").unwrap();
let mut app = App::new(None, false, None, None).unwrap();
app.quickfix.set(vec![
QfEntry {
path: file_a.clone(),
row: 0,
col: 0,
kind: QfKind::Grep,
message: "a0".into(),
},
QfEntry {
path: file_a.clone(),
row: 1,
col: 0,
kind: QfKind::Grep,
message: "a1".into(),
},
QfEntry {
path: file_b.clone(),
row: 0,
col: 0,
kind: QfKind::Grep,
message: "b0".into(),
},
]);
app.handle_quickfix_command(QfCommand::Do {
cmd: "s/^/Z/".to_string(),
per_file: true,
});
app.dispatch_ex(&format!("e {}", file_a.display()));
let row0_a = buf_row(&app, 0);
let row1_a = buf_row(&app, 1);
assert!(
row0_a.starts_with('Z'),
"fileA row 0 must start with Z (cfdo visited first entry), got: {row0_a:?}"
);
assert!(
!row1_a.starts_with('Z'),
"fileA row 1 must NOT start with Z (second entry skipped by cfdo), got: {row1_a:?}"
);
app.dispatch_ex(&format!("e {}", file_b.display()));
let row0_b = buf_row(&app, 0);
assert!(
row0_b.starts_with('Z'),
"fileB row 0 must start with Z (cfdo visited its single entry), got: {row0_b:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn populate_expr(app: &mut App, text: &str) {
app.handle_quickfix_command(QfCommand::Expr {
text: text.to_string(),
append: false,
jump: false,
});
}
#[test]
fn colder_restores_previous_list() {
let mut app = make_app_with_buffer();
populate_expr(&mut app, r#""2:1:a""#);
let entries_a: Vec<_> = app.quickfix.entries().to_vec();
populate_expr(&mut app, r#""4:1:b""#);
let entries_b: Vec<_> = app.quickfix.entries().to_vec();
assert_eq!(app.quickfix.entries(), entries_b.as_slice());
app.handle_quickfix_command(QfCommand::Older(1));
assert_eq!(
app.quickfix.entries(),
entries_a.as_slice(),
"colder should restore list A"
);
assert!(
app.quickfix_older.is_empty(),
"older should be empty after single colder"
);
assert_eq!(app.quickfix_newer.len(), 1, "newer should hold list B");
app.handle_quickfix_command(QfCommand::Newer(1));
assert_eq!(
app.quickfix.entries(),
entries_b.as_slice(),
"cnewer should restore list B"
);
assert!(
app.quickfix_newer.is_empty(),
"newer should be empty after cnewer"
);
}
#[test]
fn populate_while_older_discards_newer_branch() {
let mut app = make_app_with_buffer();
populate_expr(&mut app, r#""2:1:a""#);
let entries_a: Vec<_> = app.quickfix.entries().to_vec();
populate_expr(&mut app, r#""4:1:b""#);
app.handle_quickfix_command(QfCommand::Older(1));
assert_eq!(app.quickfix.entries(), entries_a.as_slice());
assert_eq!(app.quickfix_newer.len(), 1);
populate_expr(&mut app, r#""6:1:c""#);
assert!(
app.quickfix_newer.is_empty(),
"newer branch must be cleared on fresh populate"
);
let entries_c: Vec<_> = app.quickfix.entries().to_vec();
app.handle_quickfix_command(QfCommand::Newer(1));
assert_eq!(
app.quickfix.entries(),
entries_c.as_slice(),
"cnewer should be no-op when newer is empty"
);
}
#[test]
fn colder_saturates_when_no_older() {
let mut app = make_app_with_buffer();
populate_expr(&mut app, r#""2:1:a""#);
let entries_a: Vec<_> = app.quickfix.entries().to_vec();
app.handle_quickfix_command(QfCommand::Older(1));
assert_eq!(
app.quickfix.entries(),
entries_a.as_slice(),
"colder on oldest list should not change current"
);
}
#[test]
fn cnewer_saturates_when_no_newer() {
let mut app = make_app_with_buffer();
populate_expr(&mut app, r#""2:1:a""#);
let entries_a: Vec<_> = app.quickfix.entries().to_vec();
app.handle_quickfix_command(QfCommand::Newer(1));
assert_eq!(
app.quickfix.entries(),
entries_a.as_slice(),
"cnewer on newest list should not change current"
);
}
#[test]
fn loclist_colder_cnewer_work_independently() {
let mut app = make_app_with_buffer();
app.active_editor_mut().settings_mut().errorformat = "%l:%c:%m".to_string();
app.handle_loclist_command(QfCommand::Expr {
text: r#""2:1:la""#.to_string(),
append: false,
jump: false,
});
let loc_a: Vec<_> = app.loclist.entries().to_vec();
app.handle_loclist_command(QfCommand::Expr {
text: r#""4:1:lb""#.to_string(),
append: false,
jump: false,
});
let loc_b: Vec<_> = app.loclist.entries().to_vec();
assert!(app.quickfix.is_empty());
app.handle_loclist_command(QfCommand::Older(1));
assert_eq!(app.loclist.entries(), loc_a.as_slice());
app.handle_loclist_command(QfCommand::Newer(1));
assert_eq!(app.loclist.entries(), loc_b.as_slice());
}
fn make_lsp_diag(
start_row: usize,
start_col: usize,
severity: DiagSeverity,
message: &str,
) -> LspDiag {
LspDiag {
start_row,
start_col,
end_row: start_row,
end_col: start_col + message.len(),
severity,
message: message.to_string(),
source: None,
code: None,
}
}
#[test]
fn diagnostics_populates_quickfix_from_all_buffers() {
let mut app = App::new(None, false, None, None).unwrap();
app.active_mut().lsp_diags = vec![
make_lsp_diag(5, 3, DiagSeverity::Error, "type mismatch"),
make_lsp_diag(2, 0, DiagSeverity::Hint, "consider renaming"),
make_lsp_diag(2, 10, DiagSeverity::Warning, "unused variable"),
];
app.handle_quickfix_command(QfCommand::Diagnostics);
assert_eq!(app.quickfix.len(), 3, "quickfix should have 3 entries");
assert!(app.quickfix_open, "popup should be open when diags present");
let entries = app.quickfix.entries();
assert_eq!(entries[0].row, 2);
assert_eq!(entries[0].col, 0);
assert_eq!(entries[0].kind, QfKind::Note, "Hint → Note");
assert_eq!(entries[1].row, 2);
assert_eq!(entries[1].col, 10);
assert_eq!(entries[1].kind, QfKind::Warning, "Warning → Warning");
assert_eq!(entries[2].row, 5);
assert_eq!(entries[2].col, 3);
assert_eq!(entries[2].kind, QfKind::Error, "Error → Error");
assert!(app.loclist.is_empty(), "loclist must not be touched");
}
#[test]
fn ldiagnostics_uses_current_buffer_only() {
let mut app = App::new(None, false, None, None).unwrap();
app.active_mut().lsp_diags = vec![
make_lsp_diag(1, 0, DiagSeverity::Info, "info message"),
make_lsp_diag(0, 5, DiagSeverity::Warning, "warn here"),
];
app.handle_loclist_command(QfCommand::Diagnostics);
assert_eq!(app.loclist.len(), 2, "loclist should have 2 entries");
assert!(app.loclist_open, "loclist popup should be open");
let entries = app.loclist.entries();
assert_eq!(entries[0].row, 0);
assert_eq!(entries[0].col, 5);
assert_eq!(entries[0].kind, QfKind::Warning);
assert_eq!(entries[1].row, 1);
assert_eq!(entries[1].col, 0);
assert_eq!(entries[1].kind, QfKind::Info);
assert!(app.quickfix.is_empty(), "quickfix must not be touched");
assert!(!app.quickfix_open, "quickfix popup must not be open");
}
#[test]
fn diagnostics_empty_no_popup() {
let mut app = App::new(None, false, None, None).unwrap();
app.handle_quickfix_command(QfCommand::Diagnostics);
assert!(app.quickfix.is_empty(), "list must remain empty");
assert!(!app.quickfix_open, "popup must stay closed when no diags");
}