use super::*;
#[test]
fn equal_equal_in_normal_reindents_current_line() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "{\n body\n}");
app.active_mut().editor.settings_mut().shiftwidth = 4;
app.active_mut().editor.settings_mut().expandtab = true;
app.active_mut().editor.jump_cursor(1, 0);
app.sync_viewport_from_editor();
drive_chars(&mut app, "==");
assert!(app.pending_state.is_none(), "pending must clear after ==");
let lines: Vec<_> = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines,
vec!["{", " body", "}"],
"== must reindent line 1 to 4 spaces; got {lines:?}"
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Normal,
"must stay in Normal after =="
);
}
#[test]
fn eq_g_from_top_reindents_entire_buffer() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "{\nbody\n}");
app.active_mut().editor.settings_mut().shiftwidth = 4;
app.active_mut().editor.settings_mut().expandtab = true;
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
drive_chars(&mut app, "=G");
assert!(app.pending_state.is_none(), "pending must clear after =G");
let lines: Vec<_> = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines,
vec!["{", " body", "}"],
"=G must reindent whole buffer; got {lines:?}"
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Normal,
"must stay in Normal after =G"
);
}
#[test]
fn visual_line_eq_reindents_selected_lines() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "{\nbody\n}");
app.active_mut().editor.settings_mut().shiftwidth = 4;
app.active_mut().editor.settings_mut().expandtab = true;
app.active_mut().editor.jump_cursor(1, 0);
app.sync_viewport_from_editor();
use crossterm::event::{KeyCode, KeyEvent as CtKeyEvent, KeyModifiers};
hjkl_vim::handle_key(
&mut app.active_mut().editor,
CtKeyEvent::new(KeyCode::Char('V'), KeyModifiers::NONE),
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::VisualLine,
"must be in VisualLine after V"
);
let consumed = app.route_chord_key(CtKeyEvent::new(KeyCode::Char('='), KeyModifiers::NONE));
assert!(consumed, "= in VisualLine must be consumed");
let lines: Vec<_> = app.active().editor.buffer().lines().to_vec();
assert_eq!(
lines,
vec!["{", " body", "}"],
"V= must reindent the selected line; got {lines:?}"
);
assert_eq!(
app.active().editor.vim_mode(),
hjkl_engine::VimMode::Normal,
"must exit VisualLine after ="
);
}
#[test]
fn indent_flash_active_returns_range_within_window() {
let mut app = App::new(None, false, None, None).unwrap();
app.indent_flash = Some(IndentFlash {
top: 2,
bot: 5,
started_at: Instant::now(),
});
assert_eq!(
app.indent_flash_active(),
Some((2, 5)),
"fresh flash must return Some within INDENT_FLASH_DURATION"
);
assert!(app.indent_flash.is_some());
}
#[test]
fn indent_flash_active_returns_none_after_expiry() {
let mut app = App::new(None, false, None, None).unwrap();
app.indent_flash = Some(IndentFlash {
top: 0,
bot: 3,
started_at: Instant::now() - Duration::from_millis(150),
});
assert_eq!(app.indent_flash_active(), None);
assert!(
app.indent_flash.is_none(),
"field must be cleared on expiry"
);
}
#[test]
fn auto_indent_op_sets_indent_flash() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "{\n body\n}");
app.active_mut().editor.settings_mut().shiftwidth = 4;
app.active_mut().editor.settings_mut().expandtab = true;
app.active_mut().editor.jump_cursor(1, 0);
app.sync_viewport_from_editor();
app.route_chord_key(key(KeyCode::Char('=')));
app.route_chord_key(key(KeyCode::Char('=')));
assert!(
app.indent_flash.is_some(),
"indent_flash must be armed after == operator"
);
if let Some(ref f) = app.indent_flash {
assert_eq!(f.top, 1, "flash top must match indented row");
assert_eq!(f.bot, 1, "flash bot must match indented row");
}
}
#[test]
fn auto_indent_gg_eq_g_on_large_file_does_not_break_pipe() {
use std::io::Write;
if std::process::Command::new("rustfmt")
.arg("--version")
.output()
.is_err()
{
eprintln!("skipping: rustfmt not on PATH");
return;
}
let src = include_str!("../../../../../crates/hjkl-engine/src/editor.rs");
let path = std::env::temp_dir().join(format!(
"hjkl_mangler_large_{}.rs",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(src.as_bytes()).unwrap();
drop(f);
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.sync_viewport_from_editor();
app.route_chord_key(key(KeyCode::Char('g')));
app.route_chord_key(key(KeyCode::Char('g')));
app.route_chord_key(key(KeyCode::Char('=')));
app.route_chord_key(key(KeyCode::Char('G')));
let _ = std::fs::remove_file(&path);
assert!(
app.bus.last_body().is_none() || !app.bus.last_body().unwrap().contains("pipe"),
"expected no broken-pipe error; got status: {:?}",
app.bus.last_body_or_empty()
);
}
#[test]
fn auto_indent_gg_eq_g_invokes_rustfmt() {
use std::io::Write;
if std::process::Command::new("rustfmt")
.arg("--version")
.output()
.is_err()
{
eprintln!("skipping: rustfmt not on PATH");
return;
}
let path = std::env::temp_dir().join(format!(
"hjkl_mangler_gg_eq_g_{}.rs",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(b"fn main(){let x=1;let y=2;\nprintln!(\"{}\",x+y);\n}\n")
.unwrap();
drop(f);
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.sync_viewport_from_editor();
app.route_chord_key(key(KeyCode::Char('g')));
app.route_chord_key(key(KeyCode::Char('g')));
app.route_chord_key(key(KeyCode::Char('=')));
app.route_chord_key(key(KeyCode::Char('G')));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if app.poll_format_results() {
break;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for rustfmt result"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
let after = app.active().editor.buffer().as_string();
let _ = std::fs::remove_file(&path);
assert!(
after.contains("let x = 1;"),
"rustfmt output expected (`let x = 1;`); got:\n{after}\n\nstatus: {:?}",
app.bus.last_body_or_empty()
);
}
#[test]
#[ignore = "diagnostic harness — run manually with --nocapture"]
fn prettier_md_diagnostic() {
use std::io::Write;
eprintln!("=== PATH ===");
eprintln!("{}", std::env::var("PATH").unwrap_or_default());
eprintln!("\n=== Direct Command::new(\"prettier\") --version ===");
let r = std::process::Command::new("prettier")
.arg("--version")
.output();
eprintln!("{:?}", r);
eprintln!("\n=== hjkl_mangler::probe_tool ===");
eprintln!("{:?}", hjkl_mangler::probe_tool("prettier"));
eprintln!("\n=== App flow: create .md, gg=G, poll, observe status ===");
let path = std::env::temp_dir().join(format!(
"hjkl_mangler_md_diag_{}.md",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(b"# hello\n*world* with bad whitespace\n")
.unwrap();
drop(f);
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.sync_viewport_from_editor();
app.route_chord_key(key(KeyCode::Char('g')));
app.route_chord_key(key(KeyCode::Char('g')));
app.route_chord_key(key(KeyCode::Char('=')));
app.route_chord_key(key(KeyCode::Char('G')));
eprintln!(
"status right after submit: {:?}",
app.bus.last_body_or_empty()
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
if app.poll_format_results() {
eprintln!("poll_format_results returned true");
break;
}
if std::time::Instant::now() >= deadline {
eprintln!("polling timed out after 10s");
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
eprintln!("status after poll: {:?}", app.bus.last_body_or_empty());
eprintln!(
"buffer after: {:?}",
app.active().editor.buffer().as_string()
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn auto_indent_invokes_rustfmt_for_rs_files() {
use std::io::Write;
if std::process::Command::new("rustfmt")
.arg("--version")
.output()
.is_err()
{
eprintln!("skipping: rustfmt not on PATH");
return;
}
let path = std::env::temp_dir().join(format!(
"hjkl_mangler_e2e_{}.rs",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(b"fn main(){let x=1;let y=2;}\n").unwrap();
drop(f);
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.sync_viewport_from_editor();
app.route_chord_key(key(KeyCode::Char('=')));
app.route_chord_key(key(KeyCode::Char('=')));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if app.poll_format_results() {
break;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for rustfmt result"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
let after = app.active().editor.buffer().as_string();
let _ = std::fs::remove_file(&path);
assert!(
after.contains("let x = 1;"),
"rustfmt output missing `let x = 1;`. got:\n{after}\n\nstatus: {:?}",
app.bus.last_body_or_empty()
);
assert!(
after.contains("let y = 2;"),
"rustfmt output missing `let y = 2;`. got:\n{after}\n\nstatus: {:?}",
app.bus.last_body_or_empty()
);
}
#[test]
fn auto_indent_falls_back_to_dumb_for_unknown_ext() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "{\nbody\n}");
app.active_mut().editor.settings_mut().shiftwidth = 4;
app.active_mut().editor.settings_mut().expandtab = true;
app.active_mut().editor.jump_cursor(1, 0);
app.sync_viewport_from_editor();
app.route_chord_key(key(KeyCode::Char('=')));
app.route_chord_key(key(KeyCode::Char('=')));
assert!(
app.indent_flash.is_some(),
"dumb auto_indent_range must arm indent_flash when no formatter matches"
);
assert!(
!app.bus.last_body_or_empty().contains("not installed"),
"no formatter-error status for unknown-ext fallback"
);
}
#[test]
fn auto_indent_falls_back_to_dumb_for_no_registered_formatter() {
let mut app = App::new(None, false, None, None).unwrap();
seed_buffer(&mut app, "hello\nworld");
app.active_mut().filename = Some(std::path::PathBuf::from("/tmp/test_file.xyz"));
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.route_chord_key(key(KeyCode::Char('=')));
app.route_chord_key(key(KeyCode::Char('=')));
assert!(
app.indent_flash.is_some(),
"dumb auto_indent_range must arm indent_flash for unrecognised extension"
);
}
#[test]
#[ignore = "requires rustfmt on PATH"]
fn auto_indent_dispatches_to_formatter_for_known_ext() {
let mut app = App::new(None, false, None, None).unwrap();
let ugly = "fn main(){let x=1;}";
seed_buffer(&mut app, ugly);
app.active_mut().filename = Some(std::path::PathBuf::from("/tmp/hjkl_mangler_test.rs"));
app.active_mut().editor.jump_cursor(0, 0);
app.sync_viewport_from_editor();
app.route_chord_key(key(KeyCode::Char('=')));
app.route_chord_key(key(KeyCode::Char('=')));
assert!(
app.indent_flash.as_ref().is_some_and(|f| f.top == 0),
"flash must be armed at submit time with viewport top = 0"
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if app.poll_format_results() {
break;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for rustfmt result"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
let formatted = app.active().editor.buffer().as_string();
assert_ne!(
formatted, ugly,
"rustfmt must have changed the buffer content"
);
assert!(
formatted.contains("let x = 1;"),
"rustfmt output must have spaced assignment, got: {formatted}"
);
assert!(
app.bus.last_body().is_none(),
"no status error expected after successful format"
);
}
#[test]
fn auto_indent_format_result_is_undoable() {
use std::io::Write;
if std::process::Command::new("rustfmt")
.arg("--version")
.output()
.is_err()
{
eprintln!("skipping: rustfmt not on PATH");
return;
}
let path = std::env::temp_dir().join(format!(
"hjkl_mangler_undo_{}.rs",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let ugly = "fn main(){let x=1;}\n";
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(ugly.as_bytes()).unwrap();
drop(f);
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.sync_viewport_from_editor();
app.route_chord_key(key(KeyCode::Char('=')));
app.route_chord_key(key(KeyCode::Char('=')));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if app.poll_format_results() {
break;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for rustfmt result"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
let formatted = app.active().editor.buffer().as_string();
assert_ne!(
formatted,
ugly.trim_end(),
"rustfmt must have changed the buffer (sanity)"
);
app.route_chord_key(key(KeyCode::Char('u')));
let after_undo = app.active().editor.buffer().as_string();
let _ = std::fs::remove_file(&path);
assert_eq!(
after_undo.trim_end(),
ugly.trim_end(),
"undo must restore pre-format content; got:\n{after_undo}"
);
}
#[test]
fn auto_indent_double_equals_only_touches_current_line() {
use std::io::Write;
if std::process::Command::new("prettier")
.arg("--version")
.output()
.is_err()
{
eprintln!("skipping: prettier not on PATH");
return;
}
let content = concat!(
"{\n", " \"a\": 1,\n", " \"b\": 2,\n", " \"c\": 3,\n", " \"d\": 4,\n", " \"e\": 5\n", "}\n", );
let path = std::env::temp_dir().join(format!(
"hjkl_range_eq_{}.json",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(content.as_bytes()).unwrap();
drop(f);
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.sync_viewport_from_editor();
app.route_chord_key(key(KeyCode::Char('j')));
app.route_chord_key(key(KeyCode::Char('j')));
assert_eq!(app.active().editor.cursor().0, 2, "cursor must be on row 2");
app.route_chord_key(key(KeyCode::Char('=')));
app.route_chord_key(key(KeyCode::Char('=')));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if app.poll_format_results() {
break;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for prettier result"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
let after = app.active().editor.buffer().as_string();
let _ = std::fs::remove_file(&path);
assert!(
after.contains("\"a\": 1"),
"`==` on row 2 must not remove row 1; got:\n{after}"
);
assert!(
after.contains("\"b\": 2"),
"row 2 key must be present after format; got:\n{after}"
);
assert!(
after.contains("\"c\": 3"),
"`==` on row 2 must not remove row 3; got:\n{after}"
);
assert!(
after.starts_with('{'),
"whole-file output expected; got:\n{after}"
);
}
#[test]
fn auto_indent_gg_eq_g_still_reformats_whole_file() {
use std::io::Write;
if std::process::Command::new("rustfmt")
.arg("--version")
.output()
.is_err()
{
eprintln!("skipping: rustfmt not on PATH");
return;
}
let path = std::env::temp_dir().join(format!(
"hjkl_whole_file_{}.rs",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let src = b"fn main(){let x=1;let y=2;\nprintln!(\"{}\",x+y);\n}\n";
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(src).unwrap();
drop(f);
let mut app = App::new(Some(path.clone()), false, None, None).unwrap();
app.sync_viewport_from_editor();
app.route_chord_key(key(KeyCode::Char('g')));
app.route_chord_key(key(KeyCode::Char('g')));
app.route_chord_key(key(KeyCode::Char('=')));
app.route_chord_key(key(KeyCode::Char('G')));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if app.poll_format_results() {
break;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for rustfmt result"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
let after = app.active().editor.buffer().as_string();
let _ = std::fs::remove_file(&path);
assert!(
after.contains("let x = 1;"),
"gg=G must reformat whole file; got:\n{after}"
);
assert!(
after.contains("let y = 2;"),
"gg=G must reformat whole file; got:\n{after}"
);
}