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..cbeb582 100644 --- a/src/plugin/engine.rs +++ b/src/plugin/engine.rs @@ -32,6 +32,55 @@ 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")) +} + +/// 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, @@ -781,7 +830,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 +844,21 @@ 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)); } + // 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(), - filepath, + source, + dest, logger.with_source((*plugin_name).clone()), )?; plugin.spawn_method_call(gate, "on_load", (), false); @@ -829,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"); + } +} 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, ); }