#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
mod app;
mod completion;
mod embed;
mod headless;
mod host;
mod keymap_actions;
mod keymap_translate;
pub(crate) mod menu;
mod nvim_api;
mod picker;
mod picker_action;
mod picker_git;
mod picker_sources;
mod render;
mod save;
mod start_screen;
mod syntax;
mod theme;
#[cfg(test)]
pub(crate) mod test_cwd;
use anyhow::{Context, Result};
use clap::Parser;
use crossterm::{
event,
event::{KeyCode, KeyEvent, KeyModifiers},
execute, terminal,
};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::io::{self, stdout};
use std::path::PathBuf;
use tracing_subscriber::EnvFilter;
const LONG_ABOUT: &str = concat!(
"\n",
include_str!("art.txt"),
"\nvim-modal terminal editor · v",
env!("CARGO_PKG_VERSION"),
);
#[derive(Parser, Debug)]
#[command(
name = "hjkl",
version,
about = "vim-modal terminal editor",
long_about = LONG_ABOUT,
after_help = "Vim-style tokens (interspersed with FILEs):\n +N jump to 1-based line N on open\n +/PATTERN search for PATTERN on open\n +picker open the file picker\n +CMD run any other text as an ex command (e.g. +vsp, +'vsp other.rs', +set\\ nomouse)",
)]
struct Cli {
#[arg(short = 'R', long)]
readonly: bool,
#[arg(short = 'u', long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(long)]
clean: bool,
#[arg(long)]
headless: bool,
#[arg(long)]
embed: bool,
#[arg(long = "nvim-api")]
nvim_api: bool,
#[arg(short = 'c', long = "command", value_name = "CMD", action = clap::ArgAction::Append)]
commands: Vec<String>,
#[arg(short = 'q', value_name = "ERRORFILE", num_args = 0..=1, default_missing_value = "errors.err")]
quickfix: Option<String>,
#[arg(short = 'o', conflicts_with_all = ["open_vsplit", "open_tabs"])]
open_hsplit: bool,
#[arg(short = 'O', conflicts_with_all = ["open_hsplit", "open_tabs"])]
open_vsplit: bool,
#[arg(short = 'p', conflicts_with_all = ["open_hsplit", "open_vsplit"])]
open_tabs: bool,
#[arg(short = 'n')]
no_swap: bool,
#[arg(short = 'r', value_name = "FILE", num_args = 0..=1, default_missing_value = "")]
recover: Option<String>,
#[arg(short = 's', value_name = "SCRIPTIN")]
script_in: Option<PathBuf>,
#[arg(long = "allow-shell")]
allow_shell: bool,
files: Vec<PathBuf>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum FileLayout {
#[default]
Buffers, HSplit, VSplit, Tabs, }
pub struct Args {
pub files: Vec<PathBuf>,
pub line: Option<usize>,
pub pattern: Option<String>,
pub readonly: bool,
pub picker: bool,
pub config: Option<PathBuf>,
pub clean: bool,
pub headless: bool,
pub embed: bool,
pub nvim_api: bool,
pub commands: Vec<String>,
pub quickfix: Option<String>,
pub file_layout: FileLayout,
pub no_swap: bool,
pub recover: Option<String>,
pub script_in: Option<PathBuf>,
pub allow_shell: bool,
}
fn is_stdin_arg(p: &std::path::Path) -> bool {
p.as_os_str() == "-"
}
pub(crate) fn scriptin_char_to_key(c: char) -> KeyEvent {
match c {
'\x1b' => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
'\r' | '\n' => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
'\t' => KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
'\x08' | '\x7f' => KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
c2 @ '\x01'..='\x1a' => {
let letter = (b'a' + (c2 as u8 - 1)) as char;
KeyEvent::new(KeyCode::Char(letter), KeyModifiers::CONTROL)
}
c2 => KeyEvent::new(KeyCode::Char(c2), KeyModifiers::NONE),
}
}
fn split_vim_tokens(raw: Vec<String>) -> (Vec<String>, Vec<String>) {
let mut clap_args: Vec<String> = Vec::with_capacity(raw.len());
let mut vim_tokens: Vec<String> = Vec::new();
let mut after_dashdash = false;
for (i, arg) in raw.into_iter().enumerate() {
if !after_dashdash && i > 0 && arg == "--" {
after_dashdash = true;
clap_args.push(arg);
continue;
}
if !after_dashdash && i > 0 && arg.starts_with('+') && arg.len() > 1 {
vim_tokens.push(arg);
} else {
clap_args.push(arg);
}
}
(clap_args, vim_tokens)
}
fn apply_vim_tokens(args: &mut Args, vim_tokens: &[String]) -> Vec<String> {
let warnings: Vec<String> = Vec::new();
for tok in vim_tokens {
let rest = &tok[1..];
if let Some(pat) = rest.strip_prefix('/') {
args.pattern = Some(pat.to_string());
} else if let Ok(n) = rest.parse::<usize>() {
args.line = Some(n);
} else if rest == "picker" {
args.picker = true;
} else {
let cmd = rest.strip_prefix(':').unwrap_or(rest).to_string();
args.commands.push(cmd);
}
}
warnings
}
fn parse_argv(raw: Vec<String>) -> Result<(Args, Vec<String>)> {
let (clap_argv, vim_tokens) = split_vim_tokens(raw);
let cli = Cli::parse_from(clap_argv);
let mut args = Args {
files: cli.files,
line: None,
pattern: None,
readonly: cli.readonly,
picker: false,
config: cli.config,
clean: cli.clean,
headless: cli.headless || cli.embed || cli.nvim_api,
embed: cli.embed,
nvim_api: cli.nvim_api,
commands: cli.commands,
quickfix: cli.quickfix,
file_layout: if cli.open_tabs {
FileLayout::Tabs
} else if cli.open_vsplit {
FileLayout::VSplit
} else if cli.open_hsplit {
FileLayout::HSplit
} else {
FileLayout::Buffers
},
no_swap: cli.no_swap,
recover: cli.recover,
script_in: cli.script_in,
allow_shell: cli.allow_shell,
};
if let Some(path) = args.recover.as_deref()
&& !path.is_empty()
{
args.files.insert(0, PathBuf::from(path));
}
if let Some(path) = args.config.as_deref().and_then(|p| p.to_str())
&& (path == "NONE" || path == "NORC")
{
args.clean = true;
args.config = None;
}
let warnings = apply_vim_tokens(&mut args, &vim_tokens);
Ok((args, warnings))
}
fn parse_args() -> Result<Args> {
let raw: Vec<String> = std::env::args().collect();
let (args, warnings) = parse_argv(raw)?;
for w in warnings {
eprintln!("{w}");
}
Ok(args)
}
fn list_swap_files() -> io::Result<()> {
let dir = hjkl_app::swap::swap_dir()?;
let mut entries: Vec<(PathBuf, hjkl_app::swap::SwapHeader, bool)> = Vec::new();
if let Ok(rd) = std::fs::read_dir(&dir) {
for entry in rd.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("swp") {
continue;
}
if let Ok((header, _body)) = hjkl_app::swap::read_swap(&path) {
let alive = hjkl_app::swap::pid_is_alive(header.writer_pid);
entries.push((path, header, alive));
}
}
}
print!("{}", format_swap_listing(&entries));
Ok(())
}
fn format_swap_listing(entries: &[(PathBuf, hjkl_app::swap::SwapHeader, bool)]) -> String {
if entries.is_empty() {
return "No swap files found\n".to_string();
}
let mut sorted: Vec<&(PathBuf, hjkl_app::swap::SwapHeader, bool)> = entries.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
let mut out = String::new();
out.push_str("Swap files found:\n");
for (swap_path, header, alive) in sorted {
let file = if header.canonical_path.is_empty() {
"[No Name]"
} else {
header.canonical_path.as_str()
};
let status = if *alive {
"still running"
} else {
"process gone"
};
out.push_str(&format!(" {}\n", swap_path.display()));
out.push_str(&format!(" file: {file}\n"));
out.push_str(&format!(" pid: {} ({status})\n", header.writer_pid));
out.push_str(&format!(
" saved: {} ms since epoch\n",
header.write_time_unix_ms
));
}
out
}
fn prepend_anvil_path() {
let Ok(bin_dir) = hjkl_anvil::store::bin_dir() else {
return; };
if !bin_dir.exists() {
let _ = std::fs::create_dir_all(&bin_dir);
}
let existing = std::env::var_os("PATH").unwrap_or_default();
let mut entries = std::env::split_paths(&existing).collect::<Vec<_>>();
entries.retain(|p| p != &bin_dir);
entries.insert(0, bin_dir);
if let Ok(joined) = std::env::join_paths(&entries) {
unsafe {
std::env::set_var("PATH", joined);
}
}
}
fn main() -> Result<()> {
prepend_anvil_path();
init_tracing();
let args = parse_args()?;
if args.recover.as_deref() == Some("") {
list_swap_files()?;
std::process::exit(0);
}
if args.nvim_api || args.embed || args.headless {
if args.clean || args.config.is_some() {
anyhow::bail!("--config and --clean are unsupported outside TUI mode");
}
let (cfg, _) = hjkl_app::config::load().context("config error")?;
use hjkl_config::Validate;
cfg.validate().context("config validation")?;
if !args.allow_shell {
hjkl_engine::policy::disable_shell();
}
if args.embed || args.nvim_api {
hjkl_engine::policy::restrict_fs();
}
}
if args.nvim_api {
let code = nvim_api::run(args.files)?;
std::process::exit(code);
}
if args.embed {
let code = embed::run(args.files)?;
std::process::exit(code);
}
if args.headless {
let code = headless::run(args.files, args.commands)?;
std::process::exit(code);
}
if args.clean && args.config.is_some() {
eprintln!("hjkl: warning: --clean ignores --config");
}
let cfg = if args.clean {
Ok((
hjkl_app::config::Config::default(),
hjkl_config::ConfigSource::Defaults,
))
} else {
match args.config.as_deref() {
Some(path) => hjkl_app::config::load_from(path)
.map(|c| (c, hjkl_config::ConfigSource::File(path.to_path_buf()))),
None => hjkl_app::config::load(),
}
};
let (cfg, cfg_source) = match cfg {
Ok(pair) => pair,
Err(e) => {
eprintln!("hjkl: config error: {e}");
std::process::exit(2);
}
};
let cfg_path = if args.clean {
None
} else {
match cfg_source {
hjkl_config::ConfigSource::File(p) => Some(p),
hjkl_config::ConfigSource::Defaults => {
hjkl_config::config_path::<hjkl_app::config::Config>().ok()
}
}
};
{
use hjkl_config::Validate;
if let Err(e) = cfg.validate() {
eprintln!("hjkl: config validation: {e}");
std::process::exit(2);
}
}
let read_stdin = args.files.first().is_some_and(|p| is_stdin_arg(p));
let initial_file = if read_stdin {
None
} else {
args.files.first().cloned()
};
let base_app = app::App::new(initial_file, args.readonly, args.line, args.pattern)?
.with_config(cfg.clone());
let base_app = match cfg_path {
Some(p) => base_app.with_config_path(p),
None => base_app,
};
let mut app = if cfg.lsp.enabled {
let mgr = hjkl_lsp::LspManager::spawn(cfg.lsp.clone());
base_app.with_lsp(mgr)
} else {
base_app
};
if !app.apply_named_theme(&cfg.theme.name) {
eprintln!(
"hjkl: warning: theme.name = {:?} is not a bundled colorscheme; falling back to \"dark\"",
cfg.theme.name
);
}
if args.no_swap {
app.disable_swapfiles();
}
if read_stdin {
use std::io::Read;
let mut stdin_text = String::new();
if let Err(e) = std::io::stdin().lock().read_to_string(&mut stdin_text) {
eprintln!("hjkl: reading stdin: {e}");
} else {
app.load_stdin_buffer(&stdin_text);
}
}
let extra_files: Vec<PathBuf> = args.files.into_iter().skip(1).collect();
match args.file_layout {
FileLayout::Buffers => {
for path in extra_files {
if let Err(e) = app.open_extra(path) {
eprintln!("hjkl: {e}");
}
}
}
FileLayout::HSplit => {
for path in &extra_files {
app.dispatch_ex(&format!("split {}", path.display()));
}
if !extra_files.is_empty() {
app.switch_focus(0);
}
}
FileLayout::VSplit => {
for path in &extra_files {
app.dispatch_ex(&format!("vsplit {}", path.display()));
}
if !extra_files.is_empty() {
app.switch_focus(0);
}
}
FileLayout::Tabs => {
for path in &extra_files {
app.dispatch_ex(&format!("tabnew {}", path.display()));
}
if !extra_files.is_empty() {
app.switch_tab(0);
}
}
}
if args.picker {
app.open_picker();
}
app.restore_dock_state_from_config();
app.recover_orphan_scratch_buffers();
if let Some(errfile) = &args.quickfix {
app.dispatch_ex(&format!("cfile {errfile}"));
}
for cmd in &args.commands {
app.dispatch_ex(cmd);
if app.exit_requested {
app.shutdown();
return Ok(());
}
}
if let Some(path) = &args.script_in {
match std::fs::read_to_string(path) {
Ok(script) => {
for c in script.chars() {
let key = scriptin_char_to_key(c);
match app.handle_keypress(key) {
app::event_loop::KeyOutcome::FallThrough => {
app.dispatch_fallthrough_key(key);
}
app::event_loop::KeyOutcome::Continue
| app::event_loop::KeyOutcome::Break => {}
}
if app.exit_requested {
app.shutdown();
return Ok(());
}
}
}
Err(e) => {
eprintln!("hjkl: -s {}: {e}", path.display());
}
}
}
terminal::enable_raw_mode()?;
execute!(
stdout(),
terminal::EnterAlternateScreen,
event::EnableFocusChange,
event::EnableBracketedPaste
)?;
if app.mouse_enabled {
execute!(stdout(), event::EnableMouseCapture)?;
}
let _ = hjkl_kitty::enable(&mut stdout());
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
app.enable_fs_watch();
let result = app.run(&mut terminal);
let _ = hjkl_kitty::disable(&mut io::stdout());
let _ = terminal::disable_raw_mode();
let _ = execute!(
io::stdout(),
event::DisableMouseCapture,
event::DisableFocusChange,
event::DisableBracketedPaste,
terminal::LeaveAlternateScreen
);
app.shutdown();
result
}
fn init_tracing() {
let data_dir = match hjkl_config::data_dir("hjkl") {
Ok(dir) => dir,
Err(e) => {
eprintln!("hjkl: tracing disabled (data_dir): {e}");
return;
}
};
let log_dir = data_dir.join("logs");
if let Err(e) = std::fs::create_dir_all(&log_dir) {
eprintln!(
"hjkl: tracing disabled (create log dir {}): {e}",
log_dir.display()
);
return;
}
let log_path = log_dir.join("hjkl.log");
let file = match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
Ok(f) => f,
Err(e) => {
eprintln!(
"hjkl: tracing disabled (open log file {}): {e}",
log_path.display()
);
return;
}
};
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let subscriber = tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_ansi(false)
.with_writer(move || file.try_clone().expect("clone hjkl.log file handle"))
.finish();
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
eprintln!("hjkl: tracing disabled (set global subscriber): {e}");
}
}
#[cfg(test)]
mod cli_tests {
use super::*;
use clap::CommandFactory;
#[test]
fn version_flag_returns_pkg_version() {
let cmd = Cli::command();
let version = cmd.render_version();
assert!(
version.contains(env!("CARGO_PKG_VERSION")),
"render_version output {version:?} missing CARGO_PKG_VERSION"
);
}
#[test]
fn long_help_contains_ascii_art() {
let mut cmd = Cli::command();
let help = cmd.render_long_help().to_string();
assert!(
help.contains(include_str!("art.txt")),
"long_help missing embedded art.txt block; got:\n{help}"
);
}
#[test]
fn long_help_contains_pkg_version() {
let mut cmd = Cli::command();
let help = cmd.render_long_help().to_string();
assert!(
help.contains(env!("CARGO_PKG_VERSION")),
"long_help missing CARGO_PKG_VERSION; got:\n{help}"
);
}
#[test]
fn long_help_advertises_config_flag() {
let mut cmd = Cli::command();
let help = cmd.render_long_help().to_string();
assert!(
help.contains("--config"),
"long_help should advertise --config; got:\n{help}"
);
}
#[test]
fn split_vim_tokens_separates_plus_args() {
let raw: Vec<String> = ["hjkl", "src/main.rs", "+42", "+/foo", "+picker", "-R"]
.iter()
.map(|s| s.to_string())
.collect();
let (clap_argv, vim) = split_vim_tokens(raw);
assert_eq!(clap_argv, vec!["hjkl", "src/main.rs", "-R"]);
assert_eq!(vim, vec!["+42", "+/foo", "+picker"]);
}
#[test]
fn apply_vim_tokens_plus_debug_queues_ex_command() {
let mut args = blank_args();
let warnings = apply_vim_tokens(&mut args, &["+debug".into()]);
assert_eq!(args.commands, vec!["debug".to_string()]);
assert!(warnings.is_empty());
}
#[test]
fn apply_vim_tokens_sets_line_pattern_picker() {
let mut args = blank_args();
let warnings = apply_vim_tokens(
&mut args,
&["+42".into(), "+/needle".into(), "+picker".into()],
);
assert_eq!(args.line, Some(42));
assert_eq!(args.pattern.as_deref(), Some("needle"));
assert!(args.picker);
assert!(warnings.is_empty());
}
#[test]
fn split_vim_tokens_passes_bare_plus_to_clap() {
let raw: Vec<String> = ["hjkl", "+", "file.txt"]
.iter()
.map(|s| s.to_string())
.collect();
let (clap_argv, vim) = split_vim_tokens(raw);
assert_eq!(clap_argv, vec!["hjkl", "+", "file.txt"]);
assert!(vim.is_empty());
}
#[test]
fn split_vim_tokens_honors_dashdash_separator() {
let raw: Vec<String> = ["hjkl", "+10", "--", "+42", "+/notapattern"]
.iter()
.map(|s| s.to_string())
.collect();
let (clap_argv, vim) = split_vim_tokens(raw);
assert_eq!(clap_argv, vec!["hjkl", "--", "+42", "+/notapattern"]);
assert_eq!(vim, vec!["+10"]);
}
#[test]
fn apply_vim_tokens_last_write_wins() {
let mut args = blank_args();
let _ = apply_vim_tokens(
&mut args,
&[
"+10".into(),
"+20".into(),
"+/first".into(),
"+/second".into(),
],
);
assert_eq!(args.line, Some(20));
assert_eq!(args.pattern.as_deref(), Some("second"));
}
#[test]
fn apply_vim_tokens_unknown_token_pushes_ex_command() {
let mut args = blank_args();
args.line = Some(7); let warnings = apply_vim_tokens(
&mut args,
&["+vsp".into(), "+:wq".into(), "+set nomouse".into()],
);
assert!(
warnings.is_empty(),
"no warnings expected, got: {warnings:?}"
);
assert_eq!(
args.commands,
vec![
"vsp".to_string(),
"wq".to_string(),
"set nomouse".to_string()
],
"unknown +cmd tokens should land on args.commands with leading `:` stripped"
);
assert_eq!(args.line, Some(7));
assert_eq!(args.pattern, None);
assert!(!args.picker);
}
#[test]
fn apply_vim_tokens_empty_pattern_is_some_empty_string() {
let mut args = blank_args();
let _ = apply_vim_tokens(&mut args, &["+/".into()]);
assert_eq!(args.pattern.as_deref(), Some(""));
}
#[test]
fn parse_argv_round_trip_mixed_args() {
let raw: Vec<String> = ["hjkl", "-R", "+42", "src/main.rs", "+/foo", "+vsp"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, warnings) = parse_argv(raw).expect("parse_argv");
assert!(args.readonly);
assert_eq!(args.line, Some(42));
assert_eq!(args.pattern.as_deref(), Some("foo"));
assert_eq!(args.files, vec![PathBuf::from("src/main.rs")]);
assert!(!args.picker);
assert!(
warnings.is_empty(),
"no warnings expected, got: {warnings:?}"
);
assert_eq!(args.commands, vec!["vsp".to_string()]);
}
#[test]
fn parse_argv_clean_flag() {
let raw: Vec<String> = ["hjkl", "--clean", "src/main.rs"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert!(args.clean, "--clean must set args.clean");
assert_eq!(args.files, vec![PathBuf::from("src/main.rs")]);
let raw: Vec<String> = ["hjkl", "src/main.rs"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert!(!args.clean, "clean defaults to false without the flag");
}
#[test]
fn parse_argv_dash_u_config_alias_and_sentinels() {
let raw: Vec<String> = ["hjkl", "-u", "/some/path.toml", "f.txt"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.config, Some(PathBuf::from("/some/path.toml")));
assert!(!args.clean);
let raw: Vec<String> = ["hjkl", "-u", "NONE", "f.txt"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert!(args.clean);
assert_eq!(args.config, None);
let raw: Vec<String> = ["hjkl", "-u", "NORC"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert!(args.clean);
assert_eq!(args.config, None);
}
#[test]
fn parse_argv_dash_q_quickfix_flag() {
let raw: Vec<String> = ["hjkl", "-q", "errs.txt", "f.txt"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.quickfix.as_deref(), Some("errs.txt"));
assert_eq!(args.files, vec![PathBuf::from("f.txt")]);
let raw: Vec<String> = ["hjkl", "-q"].iter().map(|s| s.to_string()).collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.quickfix.as_deref(), Some("errors.err"));
let raw: Vec<String> = ["hjkl", "f.txt"].iter().map(|s| s.to_string()).collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.quickfix, None);
}
#[test]
fn parse_argv_file_layout_flags() {
let raw: Vec<String> = ["hjkl", "-o", "a.txt", "b.txt"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.file_layout, FileLayout::HSplit);
let raw: Vec<String> = ["hjkl", "-O", "a.txt", "b.txt"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.file_layout, FileLayout::VSplit);
let raw: Vec<String> = ["hjkl", "-p", "a.txt", "b.txt"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.file_layout, FileLayout::Tabs);
let raw: Vec<String> = ["hjkl", "a.txt", "b.txt"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.file_layout, FileLayout::Buffers);
}
#[test]
fn cli_file_layout_flags_are_mutually_exclusive() {
assert!(Cli::try_parse_from(["hjkl", "-o", "-O", "f.txt"]).is_err());
assert!(Cli::try_parse_from(["hjkl", "-o", "-p", "f.txt"]).is_err());
assert!(Cli::try_parse_from(["hjkl", "-O", "-p", "f.txt"]).is_err());
}
#[test]
fn is_stdin_arg_recognises_bare_dash_only() {
assert!(is_stdin_arg(std::path::Path::new("-")));
assert!(!is_stdin_arg(std::path::Path::new("foo")));
assert!(!is_stdin_arg(std::path::Path::new("-R")));
assert!(!is_stdin_arg(std::path::Path::new("")));
}
fn blank_args() -> Args {
Args {
files: vec![],
line: None,
pattern: None,
readonly: false,
picker: false,
config: None,
clean: false,
headless: false,
embed: false,
nvim_api: false,
commands: vec![],
quickfix: None,
file_layout: FileLayout::Buffers,
no_swap: false,
recover: None,
script_in: None,
allow_shell: false,
}
}
#[test]
fn parse_argv_dash_n_no_swap_flag() {
let raw: Vec<String> = ["hjkl", "f.txt"].iter().map(|s| s.to_string()).collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert!(!args.no_swap);
let raw: Vec<String> = ["hjkl", "-n", "f.txt"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert!(args.no_swap);
assert_eq!(args.files, vec![PathBuf::from("f.txt")]);
}
#[test]
fn parse_argv_dash_r_recover_flag() {
let raw: Vec<String> = ["hjkl", "f.txt"].iter().map(|s| s.to_string()).collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.recover, None);
let raw: Vec<String> = ["hjkl", "-r"].iter().map(|s| s.to_string()).collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.recover.as_deref(), Some(""));
let raw: Vec<String> = ["hjkl", "-r", "foo.txt"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.recover.as_deref(), Some("foo.txt"));
}
#[test]
fn parse_argv_dash_r_recover_file_becomes_active_buffer() {
let raw: Vec<String> = ["hjkl", "-r", "crashed.txt"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.files, vec![PathBuf::from("crashed.txt")]);
let raw: Vec<String> = ["hjkl", "-r", "crashed.txt", "other.txt"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(
args.files,
vec![PathBuf::from("crashed.txt"), PathBuf::from("other.txt")]
);
let raw: Vec<String> = ["hjkl", "-r", "-R"].iter().map(|s| s.to_string()).collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.recover.as_deref(), Some(""));
assert!(args.files.is_empty());
}
#[test]
fn format_swap_listing_empty_and_populated() {
assert_eq!(format_swap_listing(&[]), "No swap files found\n");
let header = hjkl_app::swap::SwapHeader {
magic: hjkl_app::swap::SwapHeader::MAGIC,
version: hjkl_app::swap::SwapHeader::VERSION,
canonical_path: "/home/user/crashed.txt".to_string(),
file_mtime_unix_ms: 1_700_000_000_000,
write_time_unix_ms: 1_700_000_001_000,
cursor: (0, 0),
writer_pid: 999_999_999, };
let out =
format_swap_listing(&[(PathBuf::from("/cache/hjkl/swap/abc123.swp"), header, false)]);
assert!(out.contains("/home/user/crashed.txt"), "got: {out}");
assert!(out.contains("/cache/hjkl/swap/abc123.swp"), "got: {out}");
assert!(out.contains("process gone"), "got: {out}");
assert!(out.contains("999999999"), "got: {out}");
}
#[test]
fn parse_argv_dash_s_scriptin_flag() {
let raw: Vec<String> = ["hjkl", "-s", "foo.txt", "file.rs"]
.iter()
.map(|s| s.to_string())
.collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.script_in, Some(PathBuf::from("foo.txt")));
assert_eq!(args.files, vec![PathBuf::from("file.rs")]);
let raw: Vec<String> = ["hjkl", "file.rs"].iter().map(|s| s.to_string()).collect();
let (args, _) = parse_argv(raw).expect("parse_argv");
assert_eq!(args.script_in, None);
}
#[test]
fn scriptin_char_to_key_decodes_special_and_plain_chars() {
assert_eq!(
scriptin_char_to_key('a'),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)
);
assert_eq!(
scriptin_char_to_key('\x1b'),
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
);
assert_eq!(
scriptin_char_to_key('\r'),
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
);
assert_eq!(
scriptin_char_to_key('\n'),
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
);
assert_eq!(
scriptin_char_to_key('\t'),
KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)
);
assert_eq!(
scriptin_char_to_key('\x08'),
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)
);
assert_eq!(
scriptin_char_to_key('\x7f'),
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)
);
assert_eq!(
scriptin_char_to_key('\x17'),
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)
);
}
}