Files
vamp/src/main.rs
T

272 lines
15 KiB
Rust

use std::{env, io, path::PathBuf};
use vamp::{session::EditorSession, tui};
mod review_cli;
// Only the first argument can be this process-to-process terminal handoff marker.
const INHERITED_SCREEN: &str = "--vamp-inherit-alternate-screen";
const HELP: &str = "vamp — modal editor with isolated Ruby plugins
Usage: vamp [--config PATH | --no-config] [FILE]
vamp [--config PATH | --no-config] --review list [--repo PATH] [--json]
vamp [--config PATH | --no-config] --review show ID [--repo PATH] [--json] [--all]
vamp [--config PATH | --no-config] --review delete REVIEW_ID COMMENT_ID [--repo PATH] [--json]
Review CLI: list/show are read-only; delete commits one comment deletion immediately.
Show defaults to unresolved comments; --all includes resolved.
Use vamp -- --review to edit a file named --review.
Default keys (configurable with config.bind):
All modes: Ctrl+p command palette; type to filter, Ctrl+j/k or arrows select, Enter accept, Esc cancel
Normal/Visual: Space is leader; Space Space opens palette; Esc cancels prefix
Normal leader: Space w saves; Space b n/p/l/d next/previous/pick/close buffer
Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by default)
tree NORMAL: hjkl navigate; INSERT: type to filter, Ctrl+hjkl navigate; Enter toggles/opens
i filters; Esc retains filter and returns NORMAL, then editor; Ctrl+u clears in either mode
Ctrl+t view, Ctrl+w close, Ctrl+r refresh
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
h/j/k/l move, 0/$ line ends, x/Delete cut hovered graphemes, J join lines
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
Normal/Visual: f<char>/F<char> seek next/previous on current line; 2fx seeks second x
gf follows Markdown file links/reference links and heading fragments (no LSP)
Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g
Normal/Visual: <line>g jumps to that one-based source line at first nonblank, clamped at EOF
Ctrl+o older jump location; Ctrl+i newer (Tab too outside comparisons; counts supported)
_ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes
Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
Normal/Visual: Ctrl+u/d scroll up/down without moving carets; configurable distance (default half viewport), counts supported
u undo, U redo, : command, / search, * search word, n/N next/previous match
yy copies whole lines; dd deletes them; cc changes them and enters Insert; counts: 3yy, 2dd, 2cc
dw/db cut forward/backward words on the current line; cw/cb cut the same spans and enter Insert
Word counts before/after the operator multiply: d2w, 2cw, 2c3b; newlines are preserved
Linewise p/P paste below/above the current row; characterwise p/P paste after/before the grapheme
Normal/Visual: . repeats last native edit at current targets (one undo group)
v characterwise select, V linewise select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below
y/d/x/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
Esc keeps only the primary caret
Visual: motions extend selections, y copy, d/x/Delete cut, c change, p/P replace selections
: opens command input with the selection preserved; Esc returns to that selection
r<char> replaces every selected grapheme, preserving newlines; one undo action
I/A insert at each selected line's first nonblank/end
Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all
v/V switches selection shape; same selector or Esc cancels retaining carets
Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+j/k next/previous
Candidates replace text live; Original text restores it; other keys close and act normally
Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends
Outside snippets Enter inserts newline, Tab inserts a tab or configured spaces; Esc returns to Normal
Insert/Command/Search: Ctrl+u deletes backward to the logical line start
Command/Search: Up/Ctrl+k older history; Down/Ctrl+j newer history or unfinished input; Enter submits
Command: Tab/Shift+Tab completes command names and cycles forward/backward with wraparound (not arguments)
Search: Enter accepts current/next literal match, Esc cancels (case-sensitive, wraps)
Commands: :save [PATH], :force_save [PATH], :quit, :force_quit,
:save_and_quit [PATH], :force_save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
Shell: :!COMMAND runs asynchronously in the project directory using $SHELL (or /bin/sh)
Pending command/spinner in top bar; output/exit status in floating window; Enter/Esc closes
Aliases: :w=save, :w!=force_save, :q=quit, :q!=force_quit,
:wq=save_and_quit, :wq!=force_save_and_quit
Save actions: core.save, core.force_save, core.save_and_quit, core.force_save_and_quit
Normal saves recheck LF-normalized disk text; external changes/deletion require force
Force never bypasses snapshot read-only, symlink, or another-buffer path protection
Clean live buffers reload on 250ms metadata checks, including inactive tabs/panes
Reload preserves/clamps views and clears stale undo/redo; snapshots never reload
Dirty buffers keep edits; missing/unreadable/invalid changed files keep text and warn
Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N]
:buffers / Space b l searches IDs/paths; Enter switches, Esc/Ctrl+p cancels, Ctrl+u clears
Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged
:quit refuses any dirty buffer; both save-and-quit commands require all buffers clean
Restart: :restart closes clean buffers; unsaved buffers remain open and block restart with a warning
:force_restart / :restart! discards unsaved text without writing
Actions: core.restart, core.force_restart (no default binding)
Relaunches the executable with original CLI args/config/startup file; tabs/views reset
Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close
Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits
Markdown links: :follow_link (primary caret; local files only)
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
All text panes use native motions, selections and copy; read-only panes reject edits/saves
Switching Before/After preserves the aligned cursor row and display column
Selector queries own printable keys, including :; text navigation skips omitted lines
Normal [/] explicitly expands context; Insert brackets remain literal text
Normal Space v cycles split/unified; Space s flips left/right vs above/below split
Defaults: config.diff_view_mode = \"split\"; config.diff_split_orientation = \"vertical\"
Working After follows descendant checkouts; config.diff_follow_working_copy = false disables it
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
Remote refs/history load into temporary comparison repos without changing active refs/checkout
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
Local reviews: :diff.comment anchors exact Visual text, Visual Line rows, or the current line
Selected comments highlight their precise text
:diff.review.edit/delete [ID], :diff.review.next/previous, :diff.review.overview
:diff.review.summary; :diff.review.verdict looks_good|changes_requested|none
:diff.review.copy copies all saved comments in CLI format using the configured clipboard
:w saves comment/summary text, including hidden drafts; verdicts/deletions commit immediately
:diff.review.save PATH exports JSON; :diff.review.open [ID|PATH] selects/imports; :diff.review.new
Normal Esc or :pane.close discards unsaved text and dismisses the floating editor
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/Drafts/All tabs, type to search
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
🙏 marks a personal review request; 👍 marks your active approval (API account)
Ctrl+s sort menu: 1-4 precedence, Backspace clear, Left/Right date direction
Ctrl+f filter menu: Space includes/excludes personal review states; Enter applies, Esc cancels
Sorting persists; filters last for this session; config.pull_request_sort / config.pull_request_filters set startup choices
Symbols: config.pull_request_review_requested_symbol / config.pull_request_approved_symbol
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base
config.resolve_history_layout = \"columns\" (left/base/right) or \"base_below\" (left/right above base)
:resolve.next/previous navigates differing hunks; :resolve.use_source [N] takes a hunk
:resolve.source N selects Alternative; :resolve.base N selects History base
:resolve.draft returns to Draft; :resolve.view.toggle switches Resolution/History
:w saves and checks jj; :resolve.files toggles the explorer; :resolve.close closes cleanly
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
Set VAMP_RUBY to choose the Ruby executable.
Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset
Presets: rust -> rust-analyzer, python -> ty server + ruff server, ruby -> solargraph stdio
typst -> tinymist lsp
Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] }
Server stacks: config.lsp[\"python\"] = { servers: [{ command: [...] }, { command: [...] }] }
LSP commands: :lsp.definition, :lsp.diagnostics, :lsp.hover
Semantic highlighting: automatic when the first enabled server supports semantic tokens
Hover binding: config.bind \"K\", config.core.lsp.hover (no default key)";
struct Options {
file: Option<PathBuf>,
config: Option<PathBuf>,
review: Option<review_cli::Request>,
}
fn options(inherited_screen: bool) -> Result<Option<Options>, String> {
parse_options(env::args_os().skip(1 + usize::from(inherited_screen)))
}
fn parse_options(
args: impl IntoIterator<Item = std::ffi::OsString>,
) -> Result<Option<Options>, String> {
let mut args = args.into_iter();
let mut file = None;
let mut config = None;
let mut config_selected = false;
let mut positional = false;
let mut review = None;
while let Some(arg) = args.next() {
if !positional && (arg == "--help" || arg == "-h") {
println!("{HELP}");
return Ok(None);
} else if !positional && arg == "--" {
if review.is_some() {
return Err("unexpected review argument: --".into());
}
positional = true;
} else if !positional && arg == "--review" && file.is_none() && review.is_none() {
review = Some(review_cli::Request::parse(&mut args)?);
} else if !positional && arg == "--config" {
if config_selected {
return Err("use only one of --config and --no-config".into());
}
config = Some(PathBuf::from(
args.next().ok_or("--config requires a path")?,
));
config_selected = true;
} else if !positional && arg == "--no-config" {
if config_selected {
return Err("use only one of --config and --no-config".into());
}
config_selected = true;
} else if !positional && review.is_some() {
review.as_mut().unwrap().option(&arg, &mut args)?;
} else if !positional && arg.to_string_lossy().starts_with('-') {
return Err(format!("unknown option: {}", arg.to_string_lossy()));
} else if file.replace(PathBuf::from(arg)).is_some() {
return Err("only one file can be opened".into());
}
}
if !config_selected {
let home = env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")));
config = home
.map(|home| home.join("vamp/config.rb"))
.filter(|path| path.exists());
}
Ok(Some(Options {
file,
config,
review,
}))
}
fn run() -> Result<(), String> {
let inherited_screen = env::args_os()
.nth(1)
.is_some_and(|arg| arg == INHERITED_SCREEN);
let options = options(inherited_screen)?;
if let Some(options) = &options {
if let Some(request) = &options.review {
return review_cli::run(request, options.config.as_deref());
}
}
if options.is_none() {
return Ok(());
}
tui::TerminalSession::run(inherited_screen, || run_editor(options, inherited_screen))
.map_err(|error| error.to_string())
}
fn run_editor(options: Option<Options>, inherited_screen: bool) -> io::Result<()> {
let Some(options) = options else {
return Ok(());
};
let mut session = EditorSession::open(
options.file,
tui::CAPABILITIES.iter().map(|s| (*s).to_owned()).collect(),
options.config.as_deref(),
)
.map_err(|error| io::Error::other(format!("cannot open file: {error}")))?;
tui::run(&mut session, inherited_screen)?;
let restart = session.editor().should_restart();
drop(session);
if restart {
tui::TerminalSession::prepare_restart()?;
restart_editor(inherited_screen)
.map_err(|error| io::Error::other(format!("Restart failed: {error}")))?;
}
Ok(())
}
fn restart_editor(inherited_screen: bool) -> io::Result<()> {
let executable = env::current_exe()?;
let mut command = std::process::Command::new(executable);
command.arg(INHERITED_SCREEN);
command.args(env::args_os().skip(1 + usize::from(inherited_screen)));
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
Err(command.exec())
}
#[cfg(not(unix))]
{
let status = command.status()?;
if status.success() {
Ok(())
} else {
Err(io::Error::other(format!(
"Restarted editor exited with {status}"
)))
}
}
}
fn main() {
if let Err(error) = run() {
eprintln!("vamp: {error}");
std::process::exit(1);
}
}
#[cfg(test)]
#[path = "../tests/unit/cli_args.rs"]
mod tests;