1
1
mirror of https://github.com/wez/wezterm.git synced 2024-09-11 22:37:11 +03:00

Add wezterm.plugin module, allows loading modules from git

Brief usage notes here:

```lua
local wezterm = require 'wezterm'
local a_plugin = wezterm.plugin.require "https://github.com/owner/repo"

local config = wezterm.config_builder()

a_plugin.apply_to_config(config)

return config
```

The referenced repo is expected to have a `plugin/init.lua` file,
and by convention, return a module that exports an `apply_to_config`
function that accepts at least a config builder parameter, but may
pass other parameters, or a lua table with a `config` field that maps
to a config build parameter.

`wezterm.plugin.require` will clone the repo if it doesn't already
exist and store it in the runtime dir under `plugins/NAME` where
`NAME` is derived from the repo URL.  Once cloned, the repo is
NOT automatically updated.

Only HTTP (or local filesystem) repos are allowed for the git URL;
we cannot currently use ssh for this due to conflicting version
requirements that I'll take a look at later.

`wezterm.plugin.require` will then perform `require "NAME"`,
and since the default `package.path` now includes the appropriate
location from the runtime dir, the module should load.

Two other functions are available:

`wezterm.plugin.list()` will list the plugin repos.

`wezterm.plugin.update_all()` will attempt to fast-forward or `pull
--rebase` each of the repos it finds. It doesn't currently do anything
proactive to reload the configuration afterwards; the user will need to
do that themselves.
This commit is contained in:
Wez Furlong 2023-01-31 13:36:18 -07:00
parent df12dd9d00
commit e4ae8a844d
No known key found for this signature in database
GPG Key ID: 7A7F66A31EC9B387
7 changed files with 300 additions and 5 deletions

25
Cargo.lock generated
View File

@ -1343,6 +1343,7 @@ dependencies = [
"logging",
"mux-lua",
"objc",
"plugin",
"procinfo-funcs",
"share-data",
"spawn-funcs",
@ -1775,14 +1776,16 @@ checksum = "221996f774192f0f718773def8201c4ae31f02616a54ccfc2d358bb0e5cefdec"
[[package]]
name = "git2"
version = "0.14.4"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0155506aab710a86160ddb504a480d2964d7ab5b9e62419be69e0032bc5931c"
checksum = "ccf7f68c2995f392c49fffb4f95ae2c873297830eb25c6bc4c114ce8f4562acc"
dependencies = [
"bitflags",
"libc",
"libgit2-sys",
"log",
"openssl-probe",
"openssl-sys",
"url",
]
@ -2475,13 +2478,14 @@ dependencies = [
[[package]]
name = "libgit2-sys"
version = "0.13.5+1.4.5"
version = "0.14.2+1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e5ea06c26926f1002dd553fded6cfcdc9784c1f60feeb58368b4d9b07b6dba"
checksum = "7f3d95f6b51075fe9810a7ae22c7095f12b98005ab364d8544797a825ce946a4"
dependencies = [
"cc",
"libc",
"libz-sys",
"openssl-sys",
"pkg-config",
]
@ -3644,6 +3648,19 @@ dependencies = [
"plotters-backend",
]
[[package]]
name = "plugin"
version = "0.1.0"
dependencies = [
"anyhow",
"config",
"git2",
"log",
"luahelper",
"tempfile",
"wezterm-dynamic",
]
[[package]]
name = "png"
version = "0.17.7"

View File

@ -11,7 +11,7 @@ build = "build.rs"
env_logger = "0.10"
[build-dependencies]
git2 = { version = "0.14", default-features = false }
git2 = { version = "0.16", default-features = false }
[features]
distro-defaults = []

View File

@ -248,6 +248,11 @@ pub fn make_lua_context(config_file: &Path) -> anyhow::Result<Lua> {
prefix_path(&mut path_array, &crate::HOME_DIR.join(".wezterm"));
prefix_path(&mut path_array, &crate::CONFIG_DIR);
path_array.insert(
2,
format!("{}/plugins/?/plugin/init.lua", crate::RUNTIME_DIR.display()),
);
if let Ok(exe) = std::env::current_exe() {
if let Some(path) = exe.parent() {
wezterm_mod.set(

View File

@ -24,6 +24,7 @@ mux-lua = { path = "../lua-api-crates/mux" }
procinfo-funcs = { path = "../lua-api-crates/procinfo-funcs" }
filesystem = { path = "../lua-api-crates/filesystem" }
json = { path = "../lua-api-crates/json" }
plugin = { path = "../lua-api-crates/plugin" }
share-data = { path = "../lua-api-crates/share-data" }
ssh-funcs = { path = "../lua-api-crates/ssh-funcs" }
spawn-funcs = { path = "../lua-api-crates/spawn-funcs" }

View File

@ -168,6 +168,7 @@ fn register_lua_modules() {
procinfo_funcs::register,
filesystem::register,
json::register,
plugin::register,
ssh_funcs::register,
spawn_funcs::register,
share_data::register,

View File

@ -0,0 +1,15 @@
[package]
name = "plugin"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0"
config = { path = "../../config" }
git2 = { version = "0.16", default-features = false, features = ["https"] }
log = "0.4"
luahelper = { path = "../../luahelper" }
tempfile = "3.3"
wezterm-dynamic = { path = "../../wezterm-dynamic" }

View File

@ -0,0 +1,256 @@
use anyhow::{anyhow, Context};
use config::lua::get_or_create_sub_module;
use config::lua::mlua::{self, Lua, Value};
use git2::build::CheckoutBuilder;
use git2::{Remote, Repository};
use luahelper::to_lua;
use std::path::PathBuf;
use tempfile::TempDir;
use wezterm_dynamic::{FromDynamic, ToDynamic};
#[derive(FromDynamic, ToDynamic, Debug)]
struct RepoSpec {
url: String,
component: String,
}
/// Given a URL, generate a string that can be used as a directory name.
/// The returned name must be a single valid filesystem component
fn compute_repo_dir(url: &str) -> String {
let mut dir = String::new();
for c in url.chars() {
match c {
'/' | '\\' => {
dir.push_str("sZs");
}
':' => {
dir.push_str("sCs");
}
'.' | '-' | '_' => dir.push(c),
c if c.is_alphanumeric() => dir.push(c),
c => dir.push_str(&format!("u{}", c as u32)),
}
}
dir
}
fn get_remote(repo: &Repository) -> anyhow::Result<Option<Remote>> {
let remotes = repo.remotes()?;
for remote in remotes.iter() {
if let Some(name) = remote {
let remote = repo.find_remote(name)?;
return Ok(Some(remote));
}
}
Ok(None)
}
impl RepoSpec {
fn parse(url: String) -> anyhow::Result<Self> {
let component = compute_repo_dir(&url);
if component.starts_with('.') {
anyhow::bail!("invalid repo spec {url}");
}
Ok(Self { url, component })
}
fn load_from_dir(path: PathBuf) -> anyhow::Result<Self> {
let component = path
.file_name()
.ok_or_else(|| anyhow!("missing file name!?"))?
.to_str()
.ok_or_else(|| anyhow!("{path:?} isn't unicode"))?
.to_string();
let repo = Repository::open(&path)?;
let remote = get_remote(&repo)?.ok_or_else(|| anyhow!("no remotes!?"))?;
let url = remote.url();
if let Some(url) = url {
let url = url.to_string();
return Ok(Self { component, url });
}
anyhow::bail!("Unable to create a complete RepoSpec for repo at {path:?}");
}
fn plugins_dir() -> PathBuf {
config::RUNTIME_DIR.join("plugins")
}
fn checkout_path(&self) -> PathBuf {
Self::plugins_dir().join(&self.component)
}
fn is_checked_out(&self) -> bool {
self.checkout_path().exists()
}
fn update(&self) -> anyhow::Result<()> {
let path = self.checkout_path();
let repo = Repository::open(&path)?;
let mut remote = get_remote(&repo)?.ok_or_else(|| anyhow!("no remotes!?"))?;
remote.connect(git2::Direction::Fetch).context("connect")?;
let branch = remote
.default_branch()
.context("get default branch")?
.as_str()
.ok_or_else(|| anyhow!("default branch is not utf8"))?
.to_string();
remote.fetch(&[branch], None, None).context("fetch")?;
let mut merge_info = None;
repo.fetchhead_foreach(|refname, _remote_url, target_oid, was_merge| {
if was_merge {
merge_info.replace((refname.to_string(), target_oid.clone()));
return true;
}
false
})
.context("fetchhead_foreach")?;
let (refname, target_oid) = merge_info.ok_or_else(|| anyhow!("No merge info!?"))?;
let commit = repo
.find_annotated_commit(target_oid)
.context("find_annotated_commit")?;
let (analysis, _preference) = repo.merge_analysis(&[&commit]).context("merge_analysis")?;
if analysis.is_up_to_date() {
log::debug!("{} is up to date!", self.component);
return Ok(());
}
if analysis.is_fast_forward() {
log::debug!("{} can fast forward!", self.component);
let mut reference = repo.find_reference(&refname).context("find_reference")?;
reference
.set_target(target_oid, "fast forward")
.context("set_target")?;
repo.checkout_head(Some(CheckoutBuilder::new().force()))
.context("checkout_head")?;
return Ok(());
}
log::debug!("{} will merge", self.component);
repo.merge(&[&commit], None, Some(CheckoutBuilder::new().safe()))
.context("merge")?;
Ok(())
}
fn check_out(&self) -> anyhow::Result<()> {
let plugins_dir = Self::plugins_dir();
std::fs::create_dir_all(&plugins_dir)?;
let target_dir = TempDir::new_in(&plugins_dir)?;
log::debug!("Cloning {} into temporary dir {target_dir:?}", self.url);
let _repo = Repository::clone_recurse(&self.url, target_dir.path())?;
let target_dir = target_dir.into_path();
let checkout_path = self.checkout_path();
match std::fs::rename(&target_dir, &checkout_path) {
Ok(_) => {
log::info!("Cloned {} into {checkout_path:?}", self.url);
Ok(())
}
Err(err) => {
log::error!(
"Failed to rename {target_dir:?} -> {:?}, removing temporary dir",
self.checkout_path()
);
if let Err(err) = std::fs::remove_dir_all(&target_dir) {
log::error!(
"Failed to remove {target_dir:?}: {err:#}, \
you will need to remove it manually"
);
}
Err(err.into())
}
}
}
}
fn require_plugin(lua: &Lua, url: String) -> anyhow::Result<Value> {
let spec = RepoSpec::parse(url)?;
if !spec.is_checked_out() {
spec.check_out()?;
}
let require: mlua::Function = lua.globals().get("require")?;
match require.call::<_, Value>(spec.component.to_string()) {
Ok(value) => Ok(value),
Err(err) => {
log::error!(
"Failed to require {} which is stored in {:?}: {err:#}",
spec.component,
spec.checkout_path()
);
Err(err.into())
}
}
}
fn list_plugins() -> anyhow::Result<Vec<RepoSpec>> {
let mut plugins = vec![];
let plugins_dir = RepoSpec::plugins_dir();
std::fs::create_dir_all(&plugins_dir)?;
for entry in plugins_dir.read_dir()? {
let entry = entry?;
if entry.path().is_dir() {
plugins.push(RepoSpec::load_from_dir(entry.path())?);
}
}
Ok(plugins)
}
pub fn register(lua: &Lua) -> anyhow::Result<()> {
let plugin_mod = get_or_create_sub_module(lua, "plugin")?;
plugin_mod.set(
"require",
lua.create_function(|lua: &Lua, repo_spec: String| {
require_plugin(lua, repo_spec).map_err(|e| mlua::Error::external(format!("{e:#}")))
})?,
)?;
plugin_mod.set(
"list",
lua.create_function(|lua, _: ()| {
let plugins = list_plugins().map_err(|e| mlua::Error::external(format!("{e:#}")))?;
to_lua(lua, plugins)
})?,
)?;
plugin_mod.set(
"update_all",
lua.create_function(|_, _: ()| {
let plugins = list_plugins().map_err(|e| mlua::Error::external(format!("{e:#}")))?;
for p in plugins {
match p.update() {
Ok(_) => log::info!("Updated {p:?}"),
Err(err) => log::error!("Failed to update {p:?}: {err:#}"),
}
}
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_compute_repo_dir() {
for (input, expect) in &[
("foo", "foo"),
(
"github.com/wez/wezterm-plugins",
"github.comsZswezsZswezterm-plugins",
),
("localhost:8080/repo", "localhostsCs8080sZsrepo"),
] {
let result = compute_repo_dir(input);
assert_eq!(&result, expect, "for input {input}");
}
}
}