mod app;
mod completion;
mod config;
mod editorconfig;
mod embed;
mod git;
mod git_worker;
mod headless;
mod host;
mod keymap_actions;
mod keymap_translate;
mod lang;
mod nvim_api;
mod picker;
mod picker_action;
mod picker_git;
mod picker_sources;
mod render;
mod start_screen;
mod syntax;
mod theme;
mod which_key;
use anyhow::Result;
use clap::Parser;
use crossterm::{event, 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 +perf enable the :perf overlay\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(long, value_name = "PATH")]
config: Option<PathBuf>,
#[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>,
files: Vec<PathBuf>,
}
pub struct Args {
pub files: Vec<PathBuf>,
pub line: Option<usize>,
pub pattern: Option<String>,
pub readonly: bool,
pub perf: bool,
pub picker: bool,
pub config: Option<PathBuf>,
pub headless: bool,
pub embed: bool,
pub nvim_api: bool,
pub commands: Vec<String>,
}
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 == "perf" {
args.perf = true;
} 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,
perf: false,
picker: false,
config: cli.config,
headless: cli.headless || cli.embed || cli.nvim_api,
embed: cli.embed,
nvim_api: cli.nvim_api,
commands: cli.commands,
};
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 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.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);
}
let cfg = match args.config.as_deref() {
Some(path) => config::load_from(path)
.map(|c| (c, hjkl_config::ConfigSource::File(path.to_path_buf()))),
None => config::load(),
};
let cfg = match cfg {
Ok((c, _src)) => c,
Err(e) => {
eprintln!("hjkl: config error: {e}");
std::process::exit(2);
}
};
{
use hjkl_config::Validate;
if let Err(e) = cfg.validate() {
eprintln!("hjkl: config validation: {e}");
std::process::exit(2);
}
}
if cfg.theme.name != "dark" {
eprintln!(
"hjkl: warning: theme.name = {:?} is not bundled; falling back to \"dark\"",
cfg.theme.name
);
}
let base_app = app::App::new(
args.files.first().cloned(),
args.readonly,
args.line,
args.pattern,
)?
.with_config(cfg.clone());
let mut app = if cfg.lsp.enabled {
let mgr = hjkl_lsp::LspManager::spawn(cfg.lsp.clone());
base_app.with_lsp(mgr)
} else {
base_app
};
for path in args.files.into_iter().skip(1) {
if let Err(e) = app.open_extra(path) {
eprintln!("hjkl: {e}");
}
}
if args.perf {
app.perf_overlay = true;
}
if args.picker {
app.open_picker();
}
for cmd in &args.commands {
app.dispatch_ex(cmd);
if app.exit_requested {
return Ok(());
}
}
terminal::enable_raw_mode()?;
execute!(
stdout(),
terminal::EnterAlternateScreen,
event::EnableFocusChange
)?;
if app.mouse_enabled {
execute!(stdout(), event::EnableMouseCapture)?;
}
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
let result = app.run(&mut terminal);
let _ = terminal::disable_raw_mode();
let _ = execute!(
io::stdout(),
event::DisableMouseCapture,
event::DisableFocusChange,
terminal::LeaveAlternateScreen
);
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", "+perf", "-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", "+perf"]);
}
#[test]
fn apply_vim_tokens_sets_line_pattern_perf_picker() {
let mut args = blank_args();
let warnings = apply_vim_tokens(
&mut args,
&[
"+42".into(),
"+/needle".into(),
"+perf".into(),
"+picker".into(),
],
);
assert_eq!(args.line, Some(42));
assert_eq!(args.pattern.as_deref(), Some("needle"));
assert!(args.perf);
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.perf);
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.perf);
assert!(!args.picker);
assert!(
warnings.is_empty(),
"no warnings expected, got: {warnings:?}"
);
assert_eq!(args.commands, vec!["vsp".to_string()]);
}
fn blank_args() -> Args {
Args {
files: vec![],
line: None,
pattern: None,
readonly: false,
perf: false,
picker: false,
config: None,
headless: false,
embed: false,
nvim_api: false,
commands: vec![],
}
}
}