feat: implement modular modal editor and isolated Ruby plugins

This commit is contained in:
Valdemar Lorenzen
2026-10-05 13:48:01 +02:00
parent 6e1de421f1
commit 396ffc4f3a
11 changed files with 3450 additions and 2 deletions
+131 -2
View File
@@ -1,3 +1,132 @@
fn main() {
println!("Hello, world!");
use std::{collections::BTreeMap, env, path::PathBuf};
use vamp::{
editor::Editor,
ruby::{Config, RubyPlugin, load_config},
tui,
};
const HELP: &str = "vamp — modal editor with isolated Ruby plugins
Usage: vamp [--config PATH | --no-config] [FILE]
Normal: i/a insert, o/O new line, h/j/k/l move, 0/$ line ends,
x delete, u undo, Ctrl-r redo, : command
Insert: Esc returns to normal
Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS]
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
Set VAMP_RUBY to choose the Ruby executable.";
struct Options {
file: Option<PathBuf>,
config: Option<PathBuf>,
}
fn options() -> Result<Option<Options>, String> {
let mut args = env::args_os().skip(1);
let mut file = None;
let mut config = None;
let mut config_selected = false;
let mut positional = false;
while let Some(arg) = args.next() {
if !positional && (arg == "--help" || arg == "-h") {
println!("{HELP}");
return Ok(None);
} else if !positional && arg == "--" {
positional = true;
} 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 && 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 }))
}
fn run() -> Result<(), String> {
let Some(options) = options()? else {
return Ok(());
};
let mut editor = Editor::open(
options.file,
tui::CAPABILITIES.iter().map(|s| (*s).to_owned()).collect(),
)
.map_err(|error| format!("cannot open file: {error}"))?;
let mut notices = Vec::new();
let config = match options.config {
Some(path) => load_config(&path).unwrap_or_else(|error| {
notices.push(format!("config disabled: {error}"));
Config::default()
}),
None => Config::default(),
};
let mut plugins = BTreeMap::new();
for path in config.plugins {
match RubyPlugin::start(&path) {
Ok((plugin, feature)) => {
let name = feature.name.clone();
match editor.register(feature) {
Ok(()) => {
plugins.insert(name, plugin);
}
Err(error) => notices.push(format!("{}: {error}", path.display())),
}
}
Err(error) => notices.push(format!("{}: {error}", path.display())),
}
}
for (key, command) in config.bindings {
if let Err(error) = editor.bind(key, command) {
notices.push(format!("binding {key:?}: {error}"));
}
}
if notices.is_empty() {
editor.set_message("i: insert • Esc: normal • :w save • :q quit • :plugins");
} else {
editor.set_message(notices.join(" | "));
}
tui::run(&mut editor, |editor, invocation| {
let result = match plugins.get_mut(&invocation.plugin) {
Some(plugin) => plugin
.invoke(&invocation.command, &invocation.args, &editor.context())
.and_then(|effects| editor.apply(effects)),
None => Err("plugin host is unavailable".into()),
};
if let Err(error) = result {
if let Some(mut plugin) = plugins.remove(&invocation.plugin) {
plugin.disable();
}
editor.disable_plugin(&invocation.plugin, &error);
}
})
.map_err(|error| format!("terminal error: {error}"))
}
fn main() {
if let Err(error) = run() {
eprintln!("vamp: {error}");
std::process::exit(1);
}
}