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
+1189
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
pub mod editor;
pub mod ruby;
pub mod tui;
+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);
}
}
+727
View File
@@ -0,0 +1,727 @@
use crate::editor::{Context, Effect, Feature};
use serde::{Deserialize, Serialize};
use std::env;
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
const RUNNER: &str = include_str!("ruby_host.rb");
const MAX_FRAME: usize = 4 * 1024 * 1024;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
const INVOKE_TIMEOUT: Duration = Duration::from_millis(500);
#[derive(Debug, Default)]
pub struct Config {
pub plugins: Vec<PathBuf>,
pub bindings: Vec<(char, String)>,
}
/// Relative plugin paths are resolved against the configuration file's directory.
pub fn load_config(path: &Path) -> Result<Config, String> {
let deadline = Instant::now() + STARTUP_TIMEOUT;
let path = absolute_file(path)?;
let mut host = Host::spawn("config", &path)?;
let response = host.receive(deadline)?;
match response {
Response::Config {
plugins, bindings, ..
} => {
let mut config = Config::default();
let directory = path.parent().expect("absolute file has a parent");
for plugin in plugins {
if plugin.is_empty() || plugin.contains('\0') {
return Err("Ruby config contains an invalid plugin path".into());
}
config.plugins.push(directory.join(plugin));
}
for binding in bindings {
let mut chars = binding.key.chars();
let key = chars
.next()
.ok_or("Ruby binding key must be one character")?;
if chars.next().is_some() || binding.command.is_empty() {
return Err("Ruby binding requires one character and a nonempty command".into());
}
config.bindings.push((key, binding.command));
}
Ok(config)
}
Response::Error {
id: None, message, ..
} => Err(format!("Ruby config: {message}")),
_ => Err("Ruby config returned an unexpected protocol frame".into()),
}
}
pub struct RubyPlugin {
host: Host,
next_id: u64,
}
impl RubyPlugin {
pub fn start(path: &Path) -> Result<(Self, Feature), String> {
let deadline = Instant::now() + STARTUP_TIMEOUT;
let path = absolute_file(path)?;
let mut host = Host::spawn("plugin", &path)?;
match host.receive(deadline)? {
Response::Manifest {
name,
requires,
commands,
..
} => {
if name.is_empty()
|| requires.iter().any(String::is_empty)
|| commands.iter().any(String::is_empty)
{
return Err("Ruby plugin manifest contains empty names".into());
}
let feature = Feature {
name,
required_capabilities: requires,
commands,
core: false,
};
Ok((Self { host, next_id: 1 }, feature))
}
Response::Error {
id: None, message, ..
} => Err(format!("Ruby plugin startup: {message}")),
_ => Err("Ruby plugin returned an unexpected startup frame".into()),
}
}
/// Context coordinates are one-based; effects are queued until a full valid reply arrives.
/// Any failure shuts down this host. Starting a fresh plugin is the recovery path.
pub fn invoke(
&mut self,
command: &str,
args: &str,
context: &Context,
) -> Result<Vec<Effect>, String> {
let deadline = Instant::now() + INVOKE_TIMEOUT;
let result = self.invoke_inner(command, args, context, deadline);
if result.is_err() {
self.disable();
}
result
}
fn invoke_inner(
&mut self,
command: &str,
args: &str,
context: &Context,
deadline: Instant,
) -> Result<Vec<Effect>, String> {
let id = self.next_id;
self.next_id = id.checked_add(1).ok_or("Ruby request IDs exhausted")?;
let request = Request {
version: 1,
kind: "invoke",
id,
command,
args,
context,
};
self.host.send(&request, deadline)?;
match self.host.receive(deadline)? {
Response::Result {
id: response_id,
effects,
..
} if response_id == id => Ok(effects
.into_iter()
.map(|effect| match effect {
WireEffect::Insert { text } => Effect::Insert(text),
WireEffect::Replace { text } => Effect::Replace(text),
WireEffect::Message { text } => Effect::Message(text),
})
.collect()),
Response::Error {
id: Some(response_id),
message,
..
} if response_id == id => Err(format!("Ruby plugin: {message}")),
_ => Err("Ruby plugin response does not match the invocation".into()),
}
}
pub fn disable(&mut self) {
self.host.shutdown();
}
}
fn absolute_file(path: &Path) -> Result<PathBuf, String> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
env::current_dir()
.map(|directory| directory.join(path))
.map_err(|error| format!("Cannot resolve Ruby file path: {error}"))
}
}
#[derive(Serialize)]
struct Request<'a> {
version: u8,
#[serde(rename = "type")]
kind: &'static str,
id: u64,
command: &'a str,
args: &'a str,
context: &'a Context,
}
#[derive(Deserialize)]
#[serde(tag = "type", deny_unknown_fields)]
enum Response {
#[serde(rename = "manifest")]
Manifest {
version: u8,
name: String,
requires: Vec<String>,
commands: Vec<String>,
},
#[serde(rename = "config")]
Config {
version: u8,
plugins: Vec<String>,
bindings: Vec<Binding>,
},
#[serde(rename = "result")]
Result {
version: u8,
id: u64,
effects: Vec<WireEffect>,
},
#[serde(rename = "error")]
Error {
version: u8,
#[serde(default, deserialize_with = "optional_request_id")]
id: Option<u64>,
message: String,
},
}
fn optional_request_id<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<u64>, D::Error> {
u64::deserialize(deserializer).map(Some)
}
impl Response {
fn version(&self) -> u8 {
match self {
Self::Manifest { version, .. }
| Self::Config { version, .. }
| Self::Result { version, .. }
| Self::Error { version, .. } => *version,
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Binding {
key: String,
command: String,
}
#[derive(Deserialize)]
#[serde(tag = "type", deny_unknown_fields)]
enum WireEffect {
#[serde(rename = "insert")]
Insert { text: String },
#[serde(rename = "replace")]
Replace { text: String },
#[serde(rename = "message")]
Message { text: String },
}
struct Host {
child: Option<Child>,
requests: Option<SyncSender<Vec<u8>>>,
writes: Receiver<Result<(), String>>,
frames: Receiver<Result<Response, String>>,
reader: Option<JoinHandle<()>>,
writer: Option<JoinHandle<()>>,
}
impl Host {
fn spawn(mode: &str, path: &Path) -> Result<Self, String> {
let executable = env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into());
let mut command = Command::new(&executable);
command
.args(["-e", RUNNER, "--", mode])
.arg(path)
.current_dir(path.parent().expect("absolute file has a parent"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let mut child = command.spawn().map_err(|error| {
format!(
"Ruby runtime {:?} unavailable or failed to start: {error}",
executable
)
})?;
let mut stdin = child.stdin.take().expect("piped child stdin");
let stdout = child.stdout.take().expect("piped child stdout");
let (request_tx, request_rx) = mpsc::sync_channel::<Vec<u8>>(1);
let (write_tx, write_rx) = mpsc::sync_channel(1);
let (frame_tx, frame_rx) = mpsc::sync_channel(1);
let mut host = Self {
child: Some(child),
requests: Some(request_tx),
writes: write_rx,
frames: frame_rx,
reader: None,
writer: None,
};
host.reader = Some(
thread::Builder::new()
.name("vamp-ruby-reader".into())
.spawn(move || {
let mut reader = BufReader::new(stdout);
loop {
let frame = read_frame(&mut reader).and_then(|bytes| {
serde_json::from_slice::<Response>(&bytes)
.map_err(|error| format!("Malformed Ruby protocol frame: {error}"))
});
let failed = frame.is_err();
// Never block on a flooded host; the bounded queue disconnects after draining.
if frame_tx.try_send(frame).is_err() || failed {
break;
}
}
})
.map_err(|error| format!("Cannot start Ruby reader: {error}"))?,
);
host.writer = Some(
thread::Builder::new()
.name("vamp-ruby-writer".into())
.spawn(move || {
while let Ok(bytes) = request_rx.recv() {
let result = stdin
.write_all(&bytes)
.and_then(|_| stdin.flush())
.map_err(|error| format!("Ruby host write failed: {error}"));
let failed = result.is_err();
if write_tx.try_send(result).is_err() || failed {
break;
}
}
})
.map_err(|error| format!("Cannot start Ruby writer: {error}"))?,
);
Ok(host)
}
fn send(&mut self, request: &Request<'_>, deadline: Instant) -> Result<(), String> {
let sender = self.requests.as_ref().ok_or("Ruby plugin is disabled")?;
match self.frames.try_recv() {
Ok(_) => return Err("Ruby host emitted an unsolicited protocol frame".into()),
Err(mpsc::TryRecvError::Disconnected) => {
return Err("Ruby host crashed or closed its output".into());
}
Err(mpsc::TryRecvError::Empty) => {}
}
let mut frame = FrameWriter(Vec::new());
serde_json::to_writer(&mut frame, request)
.map_err(|error| format!("Cannot encode bounded Ruby request: {error}"))?;
frame.0.push(b'\n');
sender
.try_send(frame.0)
.map_err(|_| "Ruby host request channel failed")?;
receive_until(&self.writes, deadline, "write")?
}
fn receive(&mut self, deadline: Instant) -> Result<Response, String> {
let response = receive_until(&self.frames, deadline, "response")??;
if response.version() != 1 {
return Err("Unsupported Ruby protocol version".into());
}
Ok(response)
}
fn shutdown(&mut self) {
self.requests.take();
if let Some(mut child) = self.child.take() {
// Descendants can inherit pipes; kill the host group before joining IO threads.
#[cfg(unix)]
unsafe {
kill(-(child.id() as i32), 9);
}
let _ = child.kill();
let _ = child.wait();
}
if let Some(writer) = self.writer.take() {
let _ = writer.join();
}
if let Some(reader) = self.reader.take() {
let _ = reader.join();
}
}
}
impl Drop for Host {
fn drop(&mut self) {
self.shutdown();
}
}
#[cfg(unix)]
unsafe extern "C" {
fn kill(pid: i32, signal: i32) -> i32;
}
fn receive_until<T>(receiver: &Receiver<T>, deadline: Instant, phase: &str) -> Result<T, String> {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(format!("Ruby host {phase} timeout"));
}
receiver
.recv_timeout(remaining)
.map_err(|error| match error {
RecvTimeoutError::Timeout => format!("Ruby host {phase} timeout"),
RecvTimeoutError::Disconnected => {
format!("Ruby host crashed or closed its {phase} channel")
}
})
}
struct FrameWriter(Vec<u8>);
impl Write for FrameWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if bytes.len() > MAX_FRAME - self.0.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"protocol frame exceeds 4 MiB",
));
}
self.0.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn read_frame(reader: &mut impl BufRead) -> Result<Vec<u8>, String> {
let mut frame = Vec::new();
loop {
let chunk = reader
.fill_buf()
.map_err(|error| format!("Ruby host read failed: {error}"))?;
if chunk.is_empty() {
return Err("Ruby host crashed or closed its output before a complete frame".into());
}
let newline = chunk.iter().position(|byte| *byte == b'\n');
let length = newline.unwrap_or(chunk.len());
if length > MAX_FRAME - frame.len() {
return Err("Ruby protocol frame exceeds 4 MiB".into());
}
frame.extend_from_slice(&chunk[..length]);
reader.consume(length + usize::from(newline.is_some()));
if newline.is_some() {
return Ok(frame);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = env::temp_dir().join(format!(
"vamp-ruby-{}-{}",
std::process::id(),
NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir(&path).unwrap();
Self(path)
}
fn file(&self, name: &str, text: &str) -> PathBuf {
let path = self.0.join(name);
std::fs::write(&path, text).unwrap();
path
}
fn plugin(&self, text: &str) -> (RubyPlugin, Feature) {
RubyPlugin::start(&self.file("plugin.rb", text)).unwrap_or_else(|error| {
panic!("Actual Ruby runtime required (VAMP_RUBY or ruby): {error}")
})
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn context() -> Context {
Context {
text: "α\nhello".into(),
row: 2,
column: 3,
mode: "NORMAL".into(),
path: Some("buffer.txt".into()),
}
}
#[test]
fn actual_ruby_dsl_context_queued_effects_and_output_isolation() {
let fixture = Fixture::new();
let (mut plugin, feature) = fixture.plugin(r#"
puts "startup noise"
STDOUT.write "direct startup noise\n"
warn "warning"
Vamp.plugin "greeting", requires: ["text"]
Vamp.command "greet" do |editor, args|
puts "invocation noise"
STDOUT.write "direct invocation noise\n"
raise "bad context" unless editor.text == "α\nhello" && editor.row == 2 && editor.column == 3 && editor.mode == "NORMAL" && editor.path == "buffer.txt"
editor.insert "Hello #{args}!"
editor.replace editor.text
editor.message "Greeting inserted"
end
"#);
assert_eq!(feature.name, "greeting");
assert_eq!(feature.required_capabilities, ["text"]);
assert_eq!(feature.commands, ["greet"]);
assert!(!feature.core);
for _ in 0..2 {
let effects = plugin.invoke("greet", "friend", &context()).unwrap();
assert!(
matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message)]
if text == "Hello friend!" && replacement == "α\nhello" && message == "Greeting inserted")
);
}
plugin.disable();
plugin.disable();
assert!(
plugin
.invoke("greet", "", &context())
.unwrap_err()
.contains("disabled")
);
assert!(
plugin.host.child.is_none()
&& plugin.host.reader.is_none()
&& plugin.host.writer.is_none()
);
}
#[test]
fn config_resolves_relative_paths_and_rejects_invalid_types_and_core() {
let fixture = Fixture::new();
let config = fixture.file(
"config.rb",
r#"
puts "configuration noise"
Vamp.configure do |config|
config.plugin "plugins/greeting.rb"
config.bind "g", "greet"
end
"#,
);
let loaded = load_config(&config).unwrap();
assert_eq!(loaded.plugins, [fixture.0.join("plugins/greeting.rb")]);
assert_eq!(loaded.bindings, [('g', "greet".into())]);
let invalid = fixture.file("invalid.rb", "Vamp.configure { |config| config.plugin 7 }");
assert!(
load_config(&invalid)
.unwrap_err()
.contains("must be a String")
);
let invalid = fixture.file("core.rb", "Vamp.plugin 'evil', core: true");
assert!(RubyPlugin::start(&invalid).is_err());
let invalid = fixture.file("syntax.rb", "this is not valid ruby (");
assert!(RubyPlugin::start(&invalid).is_err());
}
#[test]
fn crash_hang_callback_failure_and_fresh_host_recovery() {
let fixture = Fixture::new();
for callback in [
"exit! 19",
"sleep 10",
"editor.insert 'must not escape'; raise 'callback failed'",
"editor.insert 7",
] {
let (mut plugin, _) = fixture.plugin(&format!(
"Vamp.plugin 'failure'\nVamp.command('fail') {{ |editor, args| {callback} }}"
));
let error = plugin.invoke("fail", "", &context()).unwrap_err();
if callback == "sleep 10" {
assert!(error.contains("timeout"), "{error}");
}
assert!(
plugin.host.child.is_none()
&& plugin.host.reader.is_none()
&& plugin.host.writer.is_none()
);
assert!(
plugin
.invoke("fail", "", &context())
.unwrap_err()
.contains("disabled")
);
let (mut recovered, _) = fixture.plugin(
"Vamp.plugin 'recovered'\nVamp.command('ok') { |editor, args| editor.message 'alive' }");
assert!(
matches!(&recovered.invoke("ok", "", &context()).unwrap()[..],
[Effect::Message(message)] if message == "alive")
);
}
}
#[test]
fn startup_and_config_hangs_are_bounded() {
let fixture = Fixture::new();
let path = fixture.file("hang.rb", "sleep 10");
assert!(RubyPlugin::start(&path).err().unwrap().contains("timeout"));
assert!(load_config(&path).unwrap_err().contains("timeout"));
}
#[test]
fn invalid_protocol_version_shape_effect_and_request_id_shutdown() {
let fixture = Fixture::new();
for mutation in [
"frame['version'] = 2",
"frame['id'] += 1",
"frame['effects'] = [{'type' => 'delete', 'text' => 'x'}]",
"frame['effects'] = [{'type' => 'insert', 'text' => 7}]",
"frame['unexpected'] = true",
] {
let source = format!(
r#"
Vamp.plugin 'protocol'
Vamp.command('ok') {{ |editor, args| editor.insert 'x' }}
module JSON
class << self
alias_method :original_generate, :generate
def generate(frame)
if frame['type'] == 'result'
{mutation}
end
original_generate(frame)
end
end
end
"#
);
let (mut plugin, _) = fixture.plugin(&source);
assert!(plugin.invoke("ok", "", &context()).is_err(), "{mutation}");
assert!(plugin.host.child.is_none());
}
}
#[test]
fn bounded_frames_and_stalled_writes_shutdown() {
let fixture = Fixture::new();
let (mut plugin, _) = fixture.plugin(
"Vamp.plugin 'large'\nVamp.command('large') { |editor, args| editor.insert 'x' * (4 * 1024 * 1024 + 1) }");
assert!(plugin.invoke("large", "", &context()).is_err());
assert!(plugin.host.child.is_none());
// Stop Ruby reading the request pipe after it sends the manifest.
let (mut plugin, _) = fixture.plugin(
r#"
Vamp.plugin 'blocked'
Vamp.command('ok') { |editor, args| editor.message 'ok' }
class IO
def gets(*args)
sleep 10
end
end
"#,
);
let mut big_context = context();
big_context.text = "x".repeat(2 * 1024 * 1024);
assert!(
plugin
.invoke("ok", "", &big_context)
.unwrap_err()
.contains("timeout")
);
assert!(plugin.host.child.is_none());
let (mut plugin, _) = fixture.plugin(
"Vamp.plugin 'request'\nVamp.command('ok') { |editor, args| editor.message 'ok' }",
);
big_context.text = "x".repeat(MAX_FRAME + 1);
assert!(
plugin
.invoke("ok", "", &big_context)
.unwrap_err()
.contains("4 MiB")
);
assert!(plugin.host.child.is_none());
for payload in ["'not json'", "'x' * (4 * 1024 * 1024 + 1)"] {
let source = format!(
r#"
Vamp.plugin 'bad_frame'
Vamp.command('ok') {{ |editor, args| editor.message 'ok' }}
class IO
alias_method :original_write, :write
def write(payload)
if payload.include?('"type":"result"')
original_write({payload})
else
original_write(payload)
end
end
end
"#
);
let (mut plugin, _) = fixture.plugin(&source);
assert!(plugin.invoke("ok", "", &context()).is_err());
assert!(plugin.host.child.is_none());
}
}
#[cfg(unix)]
#[test]
fn inherited_pipes_do_not_prevent_drop_or_timeout_cleanup() {
let fixture = Fixture::new();
let (mut plugin, _) = fixture.plugin(
r#"
Vamp.plugin 'forked'
Vamp.command('hang') do |editor, args|
fork { sleep 10 }
sleep 10
end
"#,
);
assert!(
plugin
.invoke("hang", "", &context())
.unwrap_err()
.contains("timeout")
);
assert!(plugin.host.reader.is_none() && plugin.host.writer.is_none());
let (plugin, _) = fixture.plugin("Vamp.plugin 'drop'\nfork { sleep 10 }");
drop(plugin);
}
}
+188
View File
@@ -0,0 +1,188 @@
require "json"
# Keep protocol IO separate even from explicit STDOUT.write and ordinary puts.
protocol_out = STDOUT.dup
protocol_out.binmode
protocol_out.sync = true
protocol_in = STDIN.dup
protocol_in.binmode
STDOUT.reopen(STDERR)
$stdout = STDERR
module Vamp
MAX_FRAME = 4 * 1024 * 1024
def self.string!(value, label)
raise TypeError, "#{label} must be a String" unless value.is_a?(String)
raise ArgumentError, "#{label} must be valid UTF-8" unless value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
value.dup.force_encoding(Encoding::UTF_8)
end
class Config
def initialize
@plugins = []
@bindings = []
end
def plugin(path)
path = Vamp.string!(path, "plugin path")
raise ArgumentError, "plugin path must not be empty" if path.empty?
@plugins << path
nil
end
def bind(key, command)
key = Vamp.string!(key, "binding key")
command = Vamp.string!(command, "binding command")
raise ArgumentError, "binding key must be one character" unless key.length == 1
raise ArgumentError, "binding command must not be empty" if command.empty?
@bindings << { "key" => key, "command" => command }
nil
end
def response
{ "version" => 1, "type" => "config", "plugins" => @plugins, "bindings" => @bindings }
end
end
class Editor
attr_reader :text, :row, :column, :mode, :path, :effects
def initialize(context)
unless context.is_a?(Hash) && context.keys.sort == %w[column mode path row text]
raise ArgumentError, "invalid editor context"
end
@text = Vamp.string!(context["text"], "context text").freeze
@mode = Vamp.string!(context["mode"], "context mode").freeze
@path = context["path"].nil? ? nil : Vamp.string!(context["path"], "context path").freeze
@row = context["row"]
@column = context["column"]
unless @row.is_a?(Integer) && @row > 0 && @column.is_a?(Integer) && @column > 0
raise ArgumentError, "context row and column must be positive integers"
end
@effects = []
@effect_bytes = 0
end
def insert(text)
queue("insert", text)
end
def replace(text)
queue("replace", text)
end
def message(text)
queue("message", text)
end
private
def queue(type, text)
text = Vamp.string!(text, "effect text")
@effect_bytes += text.bytesize
raise ArgumentError, "effects exceed 4 MiB" if @effect_bytes > MAX_FRAME
@effects << { "type" => type, "text" => text }
nil
end
end
def self.setup(mode)
@mode = mode
@config = Config.new
@name = nil
@requires = []
@commands = {}
end
def self.configure
raise ArgumentError, "configure is only available in config files" unless @mode == "config"
raise ArgumentError, "configure requires a block" unless block_given?
yield @config
end
def self.plugin(name, requires: [])
raise ArgumentError, "plugin is only available in plugin files" unless @mode == "plugin"
raise ArgumentError, "plugin already declared" unless @name.nil?
name = string!(name, "plugin name")
raise ArgumentError, "plugin name must not be empty" if name.empty?
raise TypeError, "requires must be an Array of Strings" unless requires.is_a?(Array)
@requires = requires.map { |value| string!(value, "capability") }
raise ArgumentError, "capability must not be empty" if @requires.any?(&:empty?)
@name = name
nil
end
def self.command(name, &callback)
raise ArgumentError, "declare a plugin before its commands" unless @mode == "plugin" && @name
name = string!(name, "command name")
raise ArgumentError, "command name must not be empty" if name.empty?
raise ArgumentError, "command requires a block" unless callback
raise ArgumentError, "duplicate command #{name}" if @commands.key?(name)
@commands[name] = callback
nil
end
def self.startup
return @config.response if @mode == "config"
raise ArgumentError, "plugin file must declare Vamp.plugin" unless @name
{ "version" => 1, "type" => "manifest", "name" => @name,
"requires" => @requires, "commands" => @commands.keys }
end
def self.invoke(request)
unless request.is_a?(Hash) && request.keys.sort == %w[args command context id type version] &&
request["version"].is_a?(Integer) && request["version"] == 1 && request["type"] == "invoke" &&
request["id"].is_a?(Integer) && request["id"] > 0
raise ArgumentError, "invalid invocation frame"
end
command = string!(request["command"], "command")
args = string!(request["args"], "args")
callback = @commands[command]
raise ArgumentError, "unknown command #{command}" unless callback
editor = Editor.new(request["context"])
callback.call(editor, args)
{ "version" => 1, "type" => "result", "id" => request["id"], "effects" => editor.effects }
end
end
send_frame = lambda do |frame|
payload = JSON.generate(frame)
raise ArgumentError, "protocol frame exceeds 4 MiB" if payload.bytesize > Vamp::MAX_FRAME
protocol_out.write(payload)
protocol_out.write("\n")
end
request_id = nil
begin
mode, path = ARGV
raise ArgumentError, "expected config or plugin mode and a path" unless %w[config plugin].include?(mode) && path
Vamp.setup(mode)
load path
send_frame.call(Vamp.startup)
if mode == "plugin"
loop do
request_id = nil
line = protocol_in.gets(Vamp::MAX_FRAME + 2)
break if line.nil?
unless line.end_with?("\n") && line.bytesize <= Vamp::MAX_FRAME + 1
raise ArgumentError, "invalid or oversized protocol frame"
end
request = JSON.parse(line)
request_id = request["id"] if request.is_a?(Hash) && request["id"].is_a?(Integer) && request["id"] > 0
send_frame.call(Vamp.invoke(request))
end
end
rescue Exception => error
# No callback effects are emitted when loading or invoking raises.
message = "#{error.class}: #{error.message}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
message = message.each_char.take(2048).join
frame = { "version" => 1, "type" => "error", "message" => message }
frame["id"] = request_id unless request_id.nil?
begin
send_frame.call(frame)
rescue Exception
# Rust observes EOF if even the error cannot be framed.
end
exit 1
end
+238
View File
@@ -0,0 +1,238 @@
use std::io::{self, Stdout};
use crossterm::{
cursor::Show,
event::{
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers,
},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::Rect,
style::{Color, Style},
text::Line,
widgets::Paragraph,
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::editor::{Editor, Effect, Input, Invocation, Mode};
pub const CAPABILITIES: &[&str] = &["text", "styled_text"];
const TAB_WIDTH: usize = 4;
struct TerminalSession;
fn restore_terminal() {
let _ = disable_raw_mode();
let _ = execute!(
io::stdout(),
DisableBracketedPaste,
LeaveAlternateScreen,
Show
);
}
impl TerminalSession {
fn enter() -> io::Result<Self> {
enable_raw_mode()?;
let session = Self;
execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?;
Ok(session)
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
restore_terminal();
}
}
/// The frontend only translates input and renders backend state.
/// Plugin dispatch is supplied by the executable, not the terminal adapter.
pub fn run(editor: &mut Editor, mut invoke: impl FnMut(&mut Editor, Invocation)) -> io::Result<()> {
let previous_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal();
previous_hook(info);
}));
let _session = TerminalSession::enter()?;
let mut terminal: Terminal<CrosstermBackend<Stdout>> =
Terminal::new(CrosstermBackend::new(io::stdout()))?;
let (mut top, mut left) = (0, 0);
while !editor.should_quit() {
terminal.draw(|frame| draw(frame, editor, &mut top, &mut left))?;
match event::read()? {
Event::Key(key) if key.kind != KeyEventKind::Release => {
if let Some(input) = translate(key)
&& let Some(invocation) = editor.handle(input)
{
invoke(editor, invocation);
}
}
// Bracketed paste must never execute pasted Ex commands.
Event::Paste(text) if editor.mode() == Mode::Insert => {
if let Err(error) = editor.apply(vec![Effect::Insert(text)]) {
editor.set_message(error);
}
}
_ => {}
}
}
Ok(())
}
fn translate(key: KeyEvent) -> Option<Input> {
if key
.modifiers
.intersects(KeyModifiers::ALT | KeyModifiers::SUPER)
{
return None;
}
Some(match key.code {
KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => Input::Ctrl(c),
KeyCode::Char(c) => Input::Char(c),
KeyCode::Esc => Input::Escape,
KeyCode::Enter => Input::Enter,
KeyCode::Backspace => Input::Backspace,
KeyCode::Delete => Input::Delete,
KeyCode::Left => Input::Left,
KeyCode::Right => Input::Right,
KeyCode::Up => Input::Up,
KeyCode::Down => Input::Down,
KeyCode::Home => Input::Home,
KeyCode::End => Input::End,
KeyCode::Tab => Input::Char('\t'),
_ => return None,
})
}
fn display_column(line: &str, grapheme_column: usize) -> usize {
line.graphemes(true)
.take(grapheme_column)
.fold(0, |column, grapheme| {
column
+ if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
UnicodeWidthStr::width(grapheme)
}
})
}
fn visible_line(line: &str, left: usize, width: usize) -> String {
let mut visible = String::new();
let mut column = 0;
let right = left.saturating_add(width);
for grapheme in line.graphemes(true) {
let cells = if grapheme == "\t" {
TAB_WIDTH - column % TAB_WIDTH
} else {
UnicodeWidthStr::width(grapheme)
};
let end = column + cells;
if column >= right {
break;
}
if end > left || (cells == 0 && column >= left) {
if grapheme == "\t" || column < left || end > right {
for _ in column.max(left)..end.min(right) {
visible.push(' ');
}
} else {
visible.push_str(grapheme);
}
}
column = end;
}
visible
}
fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) {
let area = frame.area();
if area.width == 0 || area.height < 3 {
return;
}
let height = usize::from(area.height - 2);
let width = usize::from(area.width);
let cursor = editor.cursor();
if cursor.row < *top {
*top = cursor.row;
} else if cursor.row >= top.saturating_add(height) {
*top = cursor.row + 1 - height;
}
let column = display_column(&editor.lines()[cursor.row], cursor.column);
if column < *left {
*left = column;
} else if column >= left.saturating_add(width) {
*left = column + 1 - width;
}
let lines: Vec<Line> = editor
.lines()
.iter()
.skip(*top)
.take(height)
.map(|line| Line::from(visible_line(line, *left, width)))
.collect();
frame.render_widget(
Paragraph::new(lines),
Rect::new(area.x, area.y, area.width, area.height - 2),
);
let path = editor
.path()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "[No Name]".into());
let path: String = path
.chars()
.map(|c| if c.is_control() { '�' } else { c })
.collect();
let status = format!(
" {} {}{} {}:{}",
editor.mode(),
path,
if editor.dirty() { " [+]" } else { "" },
cursor.row + 1,
cursor.column + 1
);
frame.render_widget(
Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)),
Rect::new(area.x, area.y + area.height - 2, area.width, 1),
);
let bottom = area.y + area.height - 1;
if editor.mode() == Mode::Command {
let command = format!(":{}", editor.command_line());
let command_width = UnicodeWidthStr::width(command.as_str());
let offset = command_width.saturating_sub(width - 1);
frame.render_widget(
Paragraph::new(visible_line(&command, offset, width)),
Rect::new(area.x, bottom, area.width, 1),
);
frame.set_cursor_position((area.x + (command_width - offset) as u16, bottom));
} else {
frame.render_widget(
Paragraph::new(editor.message()),
Rect::new(area.x, bottom, area.width, 1),
);
frame.set_cursor_position((
area.x + (column - *left) as u16,
area.y + (cursor.row - *top) as u16,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tabs_and_wide_graphemes_scroll_in_terminal_cells() {
let text = "a\t界e\u{301}z";
assert_eq!(display_column(text, 4), 7);
assert_eq!(visible_line(text, 2, 5), " 界e\u{301}");
assert_eq!(visible_line(text, 5, 2), " e\u{301}");
}
}