Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<config_dir>/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

Expand Down
181 changes: 177 additions & 4 deletions src/plugin/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`): `<config_dir>/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<PathBuf, String> {
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,
Expand Down Expand Up @@ -781,21 +830,35 @@ impl PluginEngine {
plugin_list: &mut HashMap<Arc<String>, 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));
}
Some(_extension) => filepath,
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);
Expand Down Expand Up @@ -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");
}
}
20 changes: 16 additions & 4 deletions src/plugin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,22 @@ pub enum PluginUnloadMode {
}

impl Plugin {
pub fn new(name: Arc<String>, filepath: PathBuf, logger: Logger) -> Result<Self, String> {
/// `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<String>,
source_filepath: PathBuf,
load_filepath: PathBuf,
logger: Logger,
) -> Result<Self, String> {
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("")
Expand All @@ -50,7 +61,7 @@ impl Plugin {

Ok(Self {
name,
filepath,
filepath: source_filepath,
lua: Rc::new(lua),
index: 0,
log_level: LogLevel::Info,
Expand Down Expand Up @@ -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,
);
}
Expand Down
Loading