From bfa7f1650c9418f7c18bf1d95f8650c84456b7c7 Mon Sep 17 00:00:00 2001 From: "Matheus T. dos Santos" Date: Fri, 17 Jul 2026 17:27:38 -0300 Subject: [PATCH 1/3] feat: copy loaded plugins into the config dir with the stdlib (#206) A plugin's require("scope") / require("shell") only resolved when the scope.lua / shell.lua standard libraries sat in the plugin's own folder, forcing the user to stage them next to every plugin. On load, copy the plugin into /scope/plugins/ and write the embedded standard libraries (scope.lua, shell.lua) there, then load the plugin from that directory so require(...) resolves with no manual staging. The original path is kept as the plugin's identity, so !plugin reload re-copies the (possibly edited) source. "scope" and "shell" are reserved plugin names. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/README.md | 4 ++- src/plugin/engine.rs | 58 +++++++++++++++++++++++++++++++++++++++++--- src/plugin/mod.rs | 20 ++++++++++++--- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/plugins/README.md b/plugins/README.md index 55c8725..24bb34d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -12,7 +12,9 @@ Ok, you already know what is a plugin, and now you may be wondering: **What I ca ## Prerequisites -Before we start to develop plugins, we need two files: [scope.lua](scope.lua) and [shell.lua](shell.lua). These two files could be found at `plugins` folder of the `Scope` repository. These files must be at same folder of our plugin. Think that files as the standard libraries of our plugin. +Plugins rely on two standard libraries — [scope.lua](scope.lua) and [shell.lua](shell.lua) — which provide the `require("scope")` and `require("shell")` modules used throughout this guide. + +You don't need to copy them next to your plugin. When you load a plugin, `Scope` copies it into `/scope/plugins/` (e.g. `~/.config/scope/plugins/`) and writes the standard libraries there alongside it, so `require(...)` resolves automatically. Because your plugin is loaded from that directory, `reload` re-copies it from the original path — keep editing the file you loaded and reload as usual. (Both libraries also live in the `plugins` folder of the `Scope` repository if you want to read them.) ## Getting Started diff --git a/src/plugin/engine.rs b/src/plugin/engine.rs index 07a7846..d7e5acd 100644 --- a/src/plugin/engine.rs +++ b/src/plugin/engine.rs @@ -32,6 +32,25 @@ use tokio::{ }; pub type PluginEngine = Task<(), PluginEngineCommand>; +/// The Scope standard-library Lua modules, embedded so they can be provisioned +/// into the plugins directory even when the source tree isn't present (installed +/// binary). Every loaded plugin sits next to these, so `require("scope")` / +/// `require("shell")` resolve without the user staging them by hand. +const STDLIB: &[(&str, &str)] = &[ + ("scope.lua", include_str!("../../plugins/scope.lua")), + ("shell.lua", include_str!("../../plugins/shell.lua")), +]; + +/// The directory scope copies loaded plugins into (alongside the bundled +/// `scope.lua`): `/scope/plugins`, e.g. `~/.config/scope/plugins`. +/// Falls back to a relative `scope/plugins` when the config dir is unknown, +/// mirroring the crash-backup directory. +fn plugins_dir() -> PathBuf { + dirs::config_dir() + .map(|dir| dir.join("scope").join("plugins")) + .unwrap_or_else(|| PathBuf::from("scope").join("plugins")) +} + pub enum PluginEngineCommand { SetLogLevel { plugin_name: String, @@ -781,7 +800,13 @@ impl PluginEngine { plugin_list: &mut HashMap, Plugin>, logger: Logger, ) -> Result<(), String> { - let filepath = match filepath.extension() { + // These names belong to the embedded standard-library modules the engine + // writes into the plugins directory; a plugin may not shadow them. + if matches!(plugin_name.as_str(), "scope" | "shell") { + return Err(format!("\"{}\" is a reserved plugin name", plugin_name)); + } + + let source = match filepath.extension() { Some(extension) if extension.as_encoded_bytes() != b"lua" => { return Err(format!("Invalid plugin extension: {:?}", extension)); } @@ -789,13 +814,38 @@ impl PluginEngine { None => filepath.with_extension("lua"), }; - if !filepath.exists() { - return Err(format!("Filepath \"{:?}\" doesn't exist!", filepath)); + if !source.exists() { + return Err(format!("Filepath \"{:?}\" doesn't exist!", source)); + } + + // Copy the plugin into the known plugins directory next to a freshly + // written copy of the `scope.lua` helper, then load it from there, so + // `require("scope")` resolves without the user staging scope.lua by + // hand (issue #206). The original path is kept as the plugin's identity + // so `!plugin reload` re-copies the (possibly edited) source. + let dir = plugins_dir(); + std::fs::create_dir_all(&dir) + .map_err(|err| format!("Cannot create plugins directory {:?}: {}", dir, err))?; + for (name, contents) in STDLIB { + let lib = dir.join(name); + std::fs::write(&lib, contents) + .map_err(|err| format!("Cannot write {:?}: {}", lib, err))?; + } + + let dest = dir.join(format!("{}.lua", plugin_name)); + let same_file = match (std::fs::canonicalize(&source), std::fs::canonicalize(&dest)) { + (Ok(s), Ok(d)) => s == d, + _ => false, + }; + if !same_file { + std::fs::copy(&source, &dest) + .map_err(|err| format!("Cannot copy plugin to {:?}: {}", dest, err))?; } let mut plugin = Plugin::new( plugin_name.clone(), - filepath, + source, + dest, logger.with_source((*plugin_name).clone()), )?; plugin.spawn_method_call(gate, "on_load", (), false); diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 7f5774b..ac09efb 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -32,11 +32,22 @@ pub enum PluginUnloadMode { } impl Plugin { - pub fn new(name: Arc, filepath: PathBuf, logger: Logger) -> Result { + /// `source_filepath` is where the plugin came from — it is stored as the + /// plugin's identity and re-read on reload. `load_filepath` is where the + /// code is actually read from and whose directory is added to Lua's + /// `package.path` (so `require("scope")` resolves against the `scope.lua` + /// sitting next to it). They differ when the engine copies the plugin into + /// the known plugins directory before loading it. + pub fn new( + name: Arc, + source_filepath: PathBuf, + load_filepath: PathBuf, + logger: Logger, + ) -> Result { let lua = Lua::new_with(mlua::StdLib::ALL_SAFE, LuaOptions::default()) .map_err(|err| err.to_string())?; - let plugin_dir = filepath.parent().unwrap_or(Path::new("/")); - let code = std::fs::read_to_string(&filepath).map_err(|err| err.to_string())?; + let plugin_dir = load_filepath.parent().unwrap_or(Path::new("/")); + let code = std::fs::read_to_string(&load_filepath).map_err(|err| err.to_string())?; lua.load(format!( "package.path = package.path .. ';{}/?.lua'", plugin_dir.to_str().unwrap_or("") @@ -50,7 +61,7 @@ impl Plugin { Ok(Self { name, - filepath, + filepath: source_filepath, lua: Rc::new(lua), index: 0, log_level: LogLevel::Info, @@ -159,6 +170,7 @@ mod tests { let _plugin = Plugin::new( Arc::new("echo".to_string()), PathBuf::from("plugins/echo.lua"), + PathBuf::from("plugins/echo.lua"), Logger::new("test".to_string()).0, ); } From 76abc43ad0bdde4047b563180c19e458f125f9d7 Mon Sep 17 00:00:00 2001 From: "Matheus T. dos Santos" Date: Fri, 17 Jul 2026 17:43:45 -0300 Subject: [PATCH 2/3] fix: only provision the plugin stdlib when missing Writing scope.lua / shell.lua on every plugin load rewrote them each time and clobbered a user's local copy. Provision each only when it isn't already in the plugins directory; the plugin itself is still copied on every load so reload keeps picking up edits. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/plugin/engine.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/plugin/engine.rs b/src/plugin/engine.rs index d7e5acd..1640270 100644 --- a/src/plugin/engine.rs +++ b/src/plugin/engine.rs @@ -828,8 +828,13 @@ impl PluginEngine { .map_err(|err| format!("Cannot create plugins directory {:?}: {}", dir, err))?; for (name, contents) in STDLIB { let lib = dir.join(name); - std::fs::write(&lib, contents) - .map_err(|err| format!("Cannot write {:?}: {}", lib, err))?; + // Provision the bundled standard library only when it isn't already + // there, so repeated loads don't rewrite it and a user's local copy + // is left untouched. + if !lib.exists() { + std::fs::write(&lib, contents) + .map_err(|err| format!("Cannot write {:?}: {}", lib, err))?; + } } let dest = dir.join(format!("{}.lua", plugin_name)); From 81378fdd9ea7d7404cc567b486c36ba4996e57c6 Mon Sep 17 00:00:00 2001 From: "Matheus T. dos Santos" Date: Fri, 17 Jul 2026 17:56:58 -0300 Subject: [PATCH 3/3] test: cover plugin staging into the config dir Extract the filesystem staging (create dir, provision the bundled stdlib only when missing, copy the plugin) out of the async load_plugin into a pure stage_plugin helper, and unit-test it: the stdlib is provisioned and the plugin copied; an existing stdlib is not overwritten; re-staging re-copies an edited source (reload); and a source already at the destination is a no-op. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/plugin/engine.rs | 174 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 146 insertions(+), 28 deletions(-) diff --git a/src/plugin/engine.rs b/src/plugin/engine.rs index 1640270..cbeb582 100644 --- a/src/plugin/engine.rs +++ b/src/plugin/engine.rs @@ -51,6 +51,36 @@ fn plugins_dir() -> PathBuf { .unwrap_or_else(|| PathBuf::from("scope").join("plugins")) } +/// Stage a plugin for loading: ensure the plugins directory and the bundled +/// standard libraries exist (each written only when missing, so repeated loads +/// don't rewrite them and a user's local copy survives), copy the plugin in +/// (unless the source already *is* the destination), and return the path to +/// load it from. Pure filesystem work, unit-tested below. +fn stage_plugin(dir: &Path, source: &Path, name: &str) -> Result { + std::fs::create_dir_all(dir) + .map_err(|err| format!("Cannot create plugins directory {:?}: {}", dir, err))?; + + for (lib_name, contents) in STDLIB { + let lib = dir.join(lib_name); + if !lib.exists() { + std::fs::write(&lib, contents) + .map_err(|err| format!("Cannot write {:?}: {}", lib, err))?; + } + } + + let dest = dir.join(format!("{}.lua", name)); + let same_file = match (std::fs::canonicalize(source), std::fs::canonicalize(&dest)) { + (Ok(s), Ok(d)) => s == d, + _ => false, + }; + if !same_file { + std::fs::copy(source, &dest) + .map_err(|err| format!("Cannot copy plugin to {:?}: {}", dest, err))?; + } + + Ok(dest) +} + pub enum PluginEngineCommand { SetLogLevel { plugin_name: String, @@ -818,34 +848,12 @@ impl PluginEngine { return Err(format!("Filepath \"{:?}\" doesn't exist!", source)); } - // Copy the plugin into the known plugins directory next to a freshly - // written copy of the `scope.lua` helper, then load it from there, so - // `require("scope")` resolves without the user staging scope.lua by - // hand (issue #206). The original path is kept as the plugin's identity - // so `!plugin reload` re-copies the (possibly edited) source. - let dir = plugins_dir(); - std::fs::create_dir_all(&dir) - .map_err(|err| format!("Cannot create plugins directory {:?}: {}", dir, err))?; - for (name, contents) in STDLIB { - let lib = dir.join(name); - // Provision the bundled standard library only when it isn't already - // there, so repeated loads don't rewrite it and a user's local copy - // is left untouched. - if !lib.exists() { - std::fs::write(&lib, contents) - .map_err(|err| format!("Cannot write {:?}: {}", lib, err))?; - } - } - - let dest = dir.join(format!("{}.lua", plugin_name)); - let same_file = match (std::fs::canonicalize(&source), std::fs::canonicalize(&dest)) { - (Ok(s), Ok(d)) => s == d, - _ => false, - }; - if !same_file { - std::fs::copy(&source, &dest) - .map_err(|err| format!("Cannot copy plugin to {:?}: {}", dest, err))?; - } + // Stage the plugin into the known plugins directory next to the bundled + // standard libraries and load it from there, so `require("scope")` / + // `require("shell")` resolve without the user staging them by hand + // (issue #206). The original path is kept as the plugin's identity so + // `!plugin reload` re-copies the (possibly edited) source. + let dest = stage_plugin(&plugins_dir(), &source, plugin_name.as_str())?; let mut plugin = Plugin::new( plugin_name.clone(), @@ -884,3 +892,113 @@ impl PluginEngineConnections { } } } + +#[cfg(test)] +mod tests { + use super::{STDLIB, stage_plugin}; + use std::fs; + use std::path::{Path, PathBuf}; + + /// A self-cleaning unique temp directory (this crate has no tempfile dev-dep). + struct TempDir(PathBuf); + + impl TempDir { + fn new(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!("scope-stage-test-{tag}")); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + TempDir(dir) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn stdlib(name: &str) -> &'static str { + STDLIB.iter().find(|(n, _)| *n == name).unwrap().1 + } + + #[test] + fn stage_provisions_stdlib_and_copies_plugin() { + let tmp = TempDir::new("provision"); + let src = tmp.path().join("src"); + fs::create_dir_all(&src).unwrap(); + let source = src.join("myplug.lua"); + fs::write(&source, "-- plugin body\n").unwrap(); + + let plugins = tmp.path().join("plugins"); + let dest = stage_plugin(&plugins, &source, "myplug").unwrap(); + + assert_eq!(dest, plugins.join("myplug.lua")); + assert_eq!(fs::read_to_string(&dest).unwrap(), "-- plugin body\n"); + assert_eq!( + fs::read_to_string(plugins.join("scope.lua")).unwrap(), + stdlib("scope.lua") + ); + assert_eq!( + fs::read_to_string(plugins.join("shell.lua")).unwrap(), + stdlib("shell.lua") + ); + } + + #[test] + fn stage_does_not_overwrite_existing_stdlib() { + let tmp = TempDir::new("no-overwrite"); + let plugins = tmp.path().join("plugins"); + fs::create_dir_all(&plugins).unwrap(); + // A user-customized scope.lua already in place. + fs::write(plugins.join("scope.lua"), "-- CUSTOM\n").unwrap(); + + let source = tmp.path().join("p.lua"); + fs::write(&source, "-- p\n").unwrap(); + stage_plugin(&plugins, &source, "p").unwrap(); + + // The existing scope.lua is preserved; the missing shell.lua is written. + assert_eq!( + fs::read_to_string(plugins.join("scope.lua")).unwrap(), + "-- CUSTOM\n" + ); + assert_eq!( + fs::read_to_string(plugins.join("shell.lua")).unwrap(), + stdlib("shell.lua") + ); + } + + #[test] + fn restage_recopies_edited_source() { + let tmp = TempDir::new("recopy"); + let plugins = tmp.path().join("plugins"); + let source = tmp.path().join("p.lua"); + + fs::write(&source, "-- v1\n").unwrap(); + let dest = stage_plugin(&plugins, &source, "p").unwrap(); + assert_eq!(fs::read_to_string(&dest).unwrap(), "-- v1\n"); + + // Editing the source and re-staging re-copies it (the reload path). + fs::write(&source, "-- v2\n").unwrap(); + let dest2 = stage_plugin(&plugins, &source, "p").unwrap(); + assert_eq!(dest2, dest); + assert_eq!(fs::read_to_string(&dest).unwrap(), "-- v2\n"); + } + + #[test] + fn stage_is_noop_copy_when_source_is_destination() { + let tmp = TempDir::new("same-file"); + let plugins = tmp.path().join("plugins"); + fs::create_dir_all(&plugins).unwrap(); + // Loading directly from the plugins dir: source == destination. + let source = plugins.join("p.lua"); + fs::write(&source, "-- inplace\n").unwrap(); + + let dest = stage_plugin(&plugins, &source, "p").unwrap(); + assert_eq!(dest, source); + assert_eq!(fs::read_to_string(&dest).unwrap(), "-- inplace\n"); + } +}