Skip to content

Commit

Permalink
Add wezterm.plugin module, allows loading modules from git
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
wez committed Feb 1, 2023
1 parent df12dd9 commit e4ae8a8
Show file tree
Hide file tree
Showing 7 changed files with 300 additions and 5 deletions.
25 changes: 21 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
5 changes: 5 additions & 0 deletions config/src/lua.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions env-bootstrap/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions env-bootstrap/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions lua-api-crates/plugin/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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" }
256 changes: 256 additions & 0 deletions lua-api-crates/plugin/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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}");
}
}
}

3 comments on commit e4ae8a8

@mrjones2014
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dude, this is super cool! I have one note, a use case which maybe you haven't thought of; my smart-splits.nvim Neovim plugin requires some custom Wezterm config for the integration with Wezterm's mux. I was thinking, now that Wezterm has a plugin system, I could bundle those configs into a Wezterm plugin so users don't have to copy much code into their Wezterm config.

However, looking at your commit message, I see this:

The referenced repo is expected to have a plugin/init.lua file,

This means that if I put my Wezterm plugin in the same repo, that file will also be loaded by Neovim itself, but Neovim doesn't actually need it.

This isn't a huge deal, it will maybe just add a few ms to initial load, or alternatively I can put it in a separate repo, but just wanted to check, since I don't see docs yet, is this WIP? Would you be open to changing that plugin/init.lua convention to something else that doesn't conflict with Neovim's runtimepath?

One other question, is there, currently or planned for the future, a way to provide configuration of the plugin itself? For example, I'd like to have a plugin that binds keymaps to special actions, but I'd like the user to be able to specify the keys.

@danjessen
Copy link

@danjessen danjessen commented on e4ae8a8 Nov 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mrjones2014 I noticed that also when looking at smart-splits.nvim. Maybe the snippet could just be updated to use the "own modules" example. I know plugin is cleaner. But a separate file would look be better until the plugin support is extended.

https://wezfurlong.org/wezterm/config/files.html?h=helpers.lua#making-your-own-lua-modules

@wez
Copy link
Owner Author

@wez wez commented on e4ae8a8 Nov 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you file an issue for this? I can't effectively track or triage comments on commits!

Please sign in to comment.