use std::any::Any;
use std::path::{Path, PathBuf};
use mlua::{Function, Lua, RegistryKey, Result as LuaResult, Table, Value, Variadic};
use parking_lot::Mutex;
use rustc_hash::FxHashMap;
use crate::theme::{ThemeBase, ThemeColors, ThemeDefinition};
use crate::traits::{CommandConfig, KeybindingConfig, Plugin, PluginCapabilities};
use crate::types::{
CustomChartConfig, CustomTableConfig, GaugePaneConfig, LogLevel, PluginContext, StatPaneConfig,
TableColumnConfig,
};
use crate::{PluginError, PluginResult};
struct LuaCommand {
name: String,
aliases: Vec<String>,
description: String,
accepts_args: bool,
callback_key: RegistryKey,
}
fn setup_noop_api(lua: &Lua, enya: &Table) -> LuaResult<()> {
enya.set(
"notify",
lua.create_function(|_, _: (String, String)| Ok(()))?,
)?;
enya.set("log", lua.create_function(|_, _: (String, String)| Ok(()))?)?;
enya.set("request_repaint", lua.create_function(|_, ()| Ok(()))?)?;
enya.set(
"editor_version",
lua.create_function(|_, ()| Ok("unknown".to_string()))?,
)?;
enya.set("is_wasm", lua.create_function(|_, ()| Ok(false))?)?;
enya.set(
"theme_name",
lua.create_function(|_, ()| Ok("unknown".to_string()))?,
)?;
enya.set(
"clipboard_write",
lua.create_function(|_, _: String| Ok(false))?,
)?;
enya.set(
"clipboard_read",
lua.create_function(|_, ()| Ok(None::<String>))?,
)?;
enya.set(
"execute",
lua.create_function(|_, _: (String, Option<String>)| Ok(false))?,
)?;
enya.set(
"http_get",
lua.create_function(|_, _: (String, Option<Table>)| -> LuaResult<Table> {
Err(mlua::Error::runtime(
"HTTP not available during plugin loading",
))
})?,
)?;
enya.set(
"http_post",
lua.create_function(
|_, _: (String, String, Option<Table>)| -> LuaResult<Table> {
Err(mlua::Error::runtime(
"HTTP not available during plugin loading",
))
},
)?,
)?;
enya.set(
"add_query_pane",
lua.create_function(|_, _: (String, Option<String>)| Ok(()))?,
)?;
enya.set("add_logs_pane", lua.create_function(|_, ()| Ok(()))?)?;
enya.set(
"add_tracing_pane",
lua.create_function(|_, _: Option<String>| Ok(()))?,
)?;
enya.set("add_terminal_pane", lua.create_function(|_, ()| Ok(()))?)?;
enya.set("add_sql_pane", lua.create_function(|_, ()| Ok(()))?)?;
enya.set("close_pane", lua.create_function(|_, ()| Ok(()))?)?;
enya.set("focus_pane", lua.create_function(|_, _: String| Ok(()))?)?;
enya.set(
"set_time_range",
lua.create_function(|_, _: String| Ok(()))?,
)?;
enya.set(
"set_time_range_absolute",
lua.create_function(|_, _: (f64, f64)| Ok(()))?,
)?;
enya.set(
"get_time_range",
lua.create_function(|lua, ()| {
let table = lua.create_table()?;
table.set("start", 0.0)?;
table.set("end", 0.0)?;
Ok(table)
})?,
)?;
enya.set(
"get_focused_pane",
lua.create_function(|_, ()| Ok(None::<Table>))?,
)?;
enya.set(
"add_custom_pane",
lua.create_function(|_, _: String| Ok(()))?,
)?;
enya.set(
"set_pane_data",
lua.create_function(|_, _: (String, Table)| Ok(()))?,
)?;
enya.set(
"add_chart_pane",
lua.create_function(|_, _: String| Ok(()))?,
)?;
enya.set(
"set_chart_data",
lua.create_function(|_, _: (String, Table)| Ok(()))?,
)?;
enya.set("add_stat_pane", lua.create_function(|_, _: String| Ok(()))?)?;
enya.set(
"set_stat_data",
lua.create_function(|_, _: (String, Table)| Ok(()))?,
)?;
enya.set(
"add_gauge_pane",
lua.create_function(|_, _: String| Ok(()))?,
)?;
enya.set(
"set_gauge_data",
lua.create_function(|_, _: (String, Table)| Ok(()))?,
)?;
Ok(())
}
pub struct LuaPlugin {
lua: Mutex<Lua>,
name: &'static str,
version: &'static str,
description: &'static str,
commands: Vec<LuaCommand>,
keybindings: Vec<KeybindingConfig>,
table_pane_types: Vec<CustomTableConfig>,
chart_pane_types: Vec<CustomChartConfig>,
stat_pane_types: Vec<StatPaneConfig>,
gauge_pane_types: Vec<GaugePaneConfig>,
refresh_callbacks: FxHashMap<String, RegistryKey>,
theme: Option<ThemeDefinition>,
path: PathBuf,
active: bool,
on_activate_key: Option<RegistryKey>,
on_deactivate_key: Option<RegistryKey>,
}
impl LuaPlugin {
pub fn load(path: &Path) -> PluginResult<Self> {
let content = std::fs::read_to_string(path).map_err(|e| {
PluginError::InitializationFailed(format!("Failed to read {}: {e}", path.display()))
})?;
Self::load_from_source(&content, path)
}
pub fn load_from_source(source: &str, path: &Path) -> PluginResult<Self> {
let lua = Lua::new();
Self::setup_registration_api(&lua).map_err(|e| {
PluginError::InitializationFailed(format!("Failed to set up Lua API: {e}"))
})?;
lua.load(source)
.set_name(path.to_string_lossy())
.exec()
.map_err(|e| {
PluginError::InitializationFailed(format!(
"Failed to execute {}: {e}",
path.display()
))
})?;
let (name, version, description) = Self::extract_metadata(&lua).map_err(|e| {
PluginError::InvalidConfiguration(format!(
"Failed to read plugin metadata from {}: {e}",
path.display()
))
})?;
let commands = Self::extract_commands(&lua).map_err(|e| {
PluginError::InvalidConfiguration(format!(
"Failed to read commands from {}: {e}",
path.display()
))
})?;
let keybindings = Self::extract_keybindings(&lua).map_err(|e| {
PluginError::InvalidConfiguration(format!(
"Failed to read keybindings from {}: {e}",
path.display()
))
})?;
let (on_activate_key, on_deactivate_key) = Self::extract_lifecycle_hooks(&lua);
let theme = Self::extract_theme(&lua);
let mut refresh_callbacks = FxHashMap::default();
let table_pane_types = Self::extract_table_pane_types(&lua, &name, &mut refresh_callbacks)
.map_err(|e| {
PluginError::InvalidConfiguration(format!(
"Failed to read table pane types from {}: {e}",
path.display()
))
})?;
let chart_pane_types = Self::extract_chart_pane_types(&lua, &name, &mut refresh_callbacks)
.map_err(|e| {
PluginError::InvalidConfiguration(format!(
"Failed to read chart pane types from {}: {e}",
path.display()
))
})?;
let stat_pane_types = Self::extract_stat_pane_types(&lua, &name, &mut refresh_callbacks)
.map_err(|e| {
PluginError::InvalidConfiguration(format!(
"Failed to read stat pane types from {}: {e}",
path.display()
))
})?;
let gauge_pane_types = Self::extract_gauge_pane_types(&lua, &name, &mut refresh_callbacks)
.map_err(|e| {
PluginError::InvalidConfiguration(format!(
"Failed to read gauge pane types from {}: {e}",
path.display()
))
})?;
Ok(Self {
lua: Mutex::new(lua),
name: Box::leak(name.into_boxed_str()),
version: Box::leak(version.into_boxed_str()),
description: Box::leak(description.into_boxed_str()),
commands,
keybindings,
table_pane_types,
chart_pane_types,
stat_pane_types,
gauge_pane_types,
refresh_callbacks,
theme,
path: path.to_path_buf(),
active: false,
on_activate_key,
on_deactivate_key,
})
}
fn setup_registration_api(lua: &Lua) -> LuaResult<()> {
let globals = lua.globals();
let enya = lua.create_table()?;
let registered_commands = lua.create_table()?;
let registered_keybindings = lua.create_table()?;
let registered_table_panes = lua.create_table()?;
let registered_chart_panes = lua.create_table()?;
let registered_stat_panes = lua.create_table()?;
let registered_gauge_panes = lua.create_table()?;
let commands_ref = registered_commands.clone();
let register_command = lua.create_function(
move |lua, (name, config, callback): (String, Table, Function)| {
let cmd_table = lua.create_table()?;
cmd_table.set("name", name)?;
cmd_table.set(
"description",
config
.get::<Option<String>>("description")?
.unwrap_or_default(),
)?;
cmd_table.set(
"aliases",
config
.get::<Option<Vec<String>>>("aliases")?
.unwrap_or_default(),
)?;
cmd_table.set(
"accepts_args",
config.get::<Option<bool>>("accepts_args")?.unwrap_or(false),
)?;
cmd_table.set("callback", callback)?;
let len = commands_ref.len()? + 1;
commands_ref.set(len, cmd_table)?;
Ok(())
},
)?;
enya.set("register_command", register_command)?;
let keybindings_ref = registered_keybindings.clone();
let keymap = lua.create_function(move |lua, args: Variadic<Value>| {
let args: Vec<Value> = args.into_iter().collect();
if args.len() < 2 {
return Err(mlua::Error::runtime(
"keymap requires at least 2 arguments: keys, command",
));
}
let keys = match &args[0] {
Value::String(s) => s.to_str()?.to_string(),
_ => return Err(mlua::Error::runtime("keys must be a string")),
};
let command = match &args[1] {
Value::String(s) => s.to_str()?.to_string(),
_ => return Err(mlua::Error::runtime("command must be a string")),
};
let description = args
.get(2)
.and_then(|v| match v {
Value::String(s) => s.to_str().ok().map(|s| s.to_string()),
_ => None,
})
.unwrap_or_default();
let modes: Vec<String> = args
.get(3)
.and_then(|v| match v {
Value::Table(t) => {
let modes: Vec<String> = t
.clone()
.pairs::<i64, String>()
.flatten()
.map(|(_, mode)| mode)
.collect();
Some(modes)
}
_ => None,
})
.unwrap_or_default();
let kb_table = lua.create_table()?;
kb_table.set("keys", keys)?;
kb_table.set("command", command)?;
kb_table.set("description", description)?;
kb_table.set("modes", modes)?;
let len = keybindings_ref.len()? + 1;
keybindings_ref.set(len, kb_table)?;
Ok(())
})?;
enya.set("keymap", keymap)?;
let table_panes_ref = registered_table_panes.clone();
let register_table_pane = lua.create_function(move |lua, args: Variadic<Value>| {
let args: Vec<Value> = args.into_iter().collect();
if args.len() < 2 {
return Err(mlua::Error::runtime(
"register_table_pane requires at least 2 arguments: name, config",
));
}
let name = match &args[0] {
Value::String(s) => s.to_str()?.to_string(),
_ => return Err(mlua::Error::runtime("name must be a string")),
};
let config = match &args[1] {
Value::Table(t) => t.clone(),
_ => return Err(mlua::Error::runtime("config must be a table")),
};
let pane_table = lua.create_table()?;
pane_table.set("name", name)?;
pane_table.set(
"title",
config
.get::<Option<String>>("title")?
.unwrap_or_else(|| "Custom Table".to_string()),
)?;
pane_table.set(
"refresh_interval",
config.get::<Option<u32>>("refresh_interval")?.unwrap_or(0),
)?;
let columns_table = lua.create_table()?;
if let Ok(columns) = config.get::<Table>("columns") {
for (idx, col) in columns.pairs::<i64, Table>().flatten() {
let col_table = lua.create_table()?;
col_table.set("name", col.get::<String>("name")?)?;
col_table.set("key", col.get::<Option<String>>("key")?.unwrap_or_default())?;
col_table.set("width", col.get::<Option<f32>>("width")?.unwrap_or(0.0))?;
columns_table.set(idx, col_table)?;
}
}
pane_table.set("columns", columns_table)?;
if let Some(Value::Function(callback)) = args.get(2) {
pane_table.set("callback", callback.clone())?;
}
let len = table_panes_ref.len()? + 1;
table_panes_ref.set(len, pane_table)?;
Ok(())
})?;
enya.set("register_table_pane", register_table_pane)?;
let chart_panes_ref = registered_chart_panes.clone();
let register_chart_pane = lua.create_function(move |lua, args: Variadic<Value>| {
let args: Vec<Value> = args.into_iter().collect();
if args.len() < 2 {
return Err(mlua::Error::runtime(
"register_chart_pane requires at least 2 arguments: name, config",
));
}
let name = match &args[0] {
Value::String(s) => s.to_str()?.to_string(),
_ => return Err(mlua::Error::runtime("name must be a string")),
};
let config = match &args[1] {
Value::Table(t) => t.clone(),
_ => return Err(mlua::Error::runtime("config must be a table")),
};
let pane_table = lua.create_table()?;
pane_table.set("name", name)?;
pane_table.set(
"title",
config
.get::<Option<String>>("title")?
.unwrap_or_else(|| "Custom Chart".to_string()),
)?;
pane_table.set(
"y_unit",
config.get::<Option<String>>("y_unit")?.unwrap_or_default(),
)?;
pane_table.set(
"refresh_interval",
config.get::<Option<u32>>("refresh_interval")?.unwrap_or(0),
)?;
if let Some(Value::Function(callback)) = args.get(2) {
pane_table.set("callback", callback.clone())?;
}
let len = chart_panes_ref.len()? + 1;
chart_panes_ref.set(len, pane_table)?;
Ok(())
})?;
enya.set("register_chart_pane", register_chart_pane)?;
let stat_panes_ref = registered_stat_panes.clone();
let register_stat_pane = lua.create_function(move |lua, args: Variadic<Value>| {
let args: Vec<Value> = args.into_iter().collect();
if args.len() < 2 {
return Err(mlua::Error::runtime(
"register_stat_pane requires at least 2 arguments: name, config",
));
}
let name = match &args[0] {
Value::String(s) => s.to_str()?.to_string(),
_ => return Err(mlua::Error::runtime("name must be a string")),
};
let config = match &args[1] {
Value::Table(t) => t.clone(),
_ => return Err(mlua::Error::runtime("config must be a table")),
};
let pane_table = lua.create_table()?;
pane_table.set("name", name)?;
pane_table.set(
"title",
config
.get::<Option<String>>("title")?
.unwrap_or_else(|| "Custom Stat".to_string()),
)?;
pane_table.set(
"unit",
config.get::<Option<String>>("unit")?.unwrap_or_default(),
)?;
pane_table.set(
"refresh_interval",
config.get::<Option<u32>>("refresh_interval")?.unwrap_or(0),
)?;
if let Some(Value::Function(callback)) = args.get(2) {
pane_table.set("callback", callback.clone())?;
}
let len = stat_panes_ref.len()? + 1;
stat_panes_ref.set(len, pane_table)?;
Ok(())
})?;
enya.set("register_stat_pane", register_stat_pane)?;
let gauge_panes_ref = registered_gauge_panes.clone();
let register_gauge_pane = lua.create_function(move |lua, args: Variadic<Value>| {
let args: Vec<Value> = args.into_iter().collect();
if args.len() < 2 {
return Err(mlua::Error::runtime(
"register_gauge_pane requires at least 2 arguments: name, config",
));
}
let name = match &args[0] {
Value::String(s) => s.to_str()?.to_string(),
_ => return Err(mlua::Error::runtime("name must be a string")),
};
let config = match &args[1] {
Value::Table(t) => t.clone(),
_ => return Err(mlua::Error::runtime("config must be a table")),
};
let pane_table = lua.create_table()?;
pane_table.set("name", name)?;
pane_table.set(
"title",
config
.get::<Option<String>>("title")?
.unwrap_or_else(|| "Custom Gauge".to_string()),
)?;
pane_table.set(
"unit",
config.get::<Option<String>>("unit")?.unwrap_or_default(),
)?;
pane_table.set("min", config.get::<Option<f64>>("min")?.unwrap_or(0.0))?;
pane_table.set("max", config.get::<Option<f64>>("max")?.unwrap_or(100.0))?;
pane_table.set(
"refresh_interval",
config.get::<Option<u32>>("refresh_interval")?.unwrap_or(0),
)?;
if let Some(Value::Function(callback)) = args.get(2) {
pane_table.set("callback", callback.clone())?;
}
let len = gauge_panes_ref.len()? + 1;
gauge_panes_ref.set(len, pane_table)?;
Ok(())
})?;
enya.set("register_gauge_pane", register_gauge_pane)?;
enya.set("_registered_commands", registered_commands)?;
enya.set("_registered_keybindings", registered_keybindings)?;
enya.set("_registered_table_panes", registered_table_panes)?;
enya.set("_registered_chart_panes", registered_chart_panes)?;
enya.set("_registered_stat_panes", registered_stat_panes)?;
enya.set("_registered_gauge_panes", registered_gauge_panes)?;
setup_noop_api(lua, &enya)?;
globals.set("enya", enya)?;
Ok(())
}
fn extract_metadata(lua: &Lua) -> LuaResult<(String, String, String)> {
let globals = lua.globals();
let plugin: Table = globals.get("plugin")?;
let name: String = plugin.get("name")?;
let version: String = plugin
.get::<Option<String>>("version")?
.unwrap_or_else(|| "0.1.0".to_string());
let description: String = plugin
.get::<Option<String>>("description")?
.unwrap_or_default();
Ok((name, version, description))
}
fn extract_commands(lua: &Lua) -> LuaResult<Vec<LuaCommand>> {
let globals = lua.globals();
let enya: Table = globals.get("enya")?;
let registered: Table = enya.get("_registered_commands")?;
let mut commands = Vec::new();
for pair in registered.pairs::<i64, Table>() {
let (_, cmd_table) = pair?;
let name: String = cmd_table.get("name")?;
let description: String = cmd_table.get("description")?;
let aliases: Vec<String> = cmd_table.get("aliases")?;
let accepts_args: bool = cmd_table.get("accepts_args")?;
let callback: Function = cmd_table.get("callback")?;
let callback_key = lua.create_registry_value(callback)?;
commands.push(LuaCommand {
name,
description,
aliases,
accepts_args,
callback_key,
});
}
Ok(commands)
}
fn extract_keybindings(lua: &Lua) -> LuaResult<Vec<KeybindingConfig>> {
let globals = lua.globals();
let enya: Table = globals.get("enya")?;
let registered: Table = enya.get("_registered_keybindings")?;
let mut keybindings = Vec::new();
for pair in registered.pairs::<i64, Table>() {
let (_, kb_table) = pair?;
keybindings.push(KeybindingConfig {
keys: kb_table.get("keys")?,
command: kb_table.get("command")?,
description: kb_table.get("description")?,
modes: kb_table.get("modes")?,
});
}
Ok(keybindings)
}
fn extract_table_pane_types(
lua: &Lua,
plugin_name: &str,
refresh_callbacks: &mut FxHashMap<String, RegistryKey>,
) -> LuaResult<Vec<CustomTableConfig>> {
let globals = lua.globals();
let enya: Table = globals.get("enya")?;
let registered: Table = enya.get("_registered_table_panes")?;
let mut pane_types = Vec::new();
for pair in registered.pairs::<i64, Table>() {
let (_, pane_table) = pair?;
let name: String = pane_table.get("name")?;
let title: String = pane_table.get("title")?;
let refresh_interval: u32 = pane_table.get("refresh_interval")?;
let columns_table: Table = pane_table.get("columns")?;
let mut columns = Vec::new();
for col_pair in columns_table.pairs::<i64, Table>() {
let (_, col) = col_pair?;
let col_name: String = col.get("name")?;
let col_key: String = col.get("key")?;
let col_width: f32 = col.get("width")?;
let mut column = TableColumnConfig::new(col_name);
if !col_key.is_empty() {
column = column.with_key(col_key);
}
if col_width > 0.0 {
let width = col_width.min(u32::MAX as f32) as u32;
column = column.with_width(width);
}
columns.push(column);
}
if let Ok(callback) = pane_table.get::<Function>("callback") {
let callback_key = lua.create_registry_value(callback)?;
refresh_callbacks.insert(name.clone(), callback_key);
}
pane_types.push(CustomTableConfig {
name,
title,
columns,
refresh_interval,
plugin_name: plugin_name.to_string(),
});
}
Ok(pane_types)
}
fn extract_chart_pane_types(
lua: &Lua,
plugin_name: &str,
refresh_callbacks: &mut FxHashMap<String, RegistryKey>,
) -> LuaResult<Vec<CustomChartConfig>> {
let globals = lua.globals();
let enya: Table = globals.get("enya")?;
let registered: Table = enya.get("_registered_chart_panes")?;
let mut pane_types = Vec::new();
for pair in registered.pairs::<i64, Table>() {
let (_, pane_table) = pair?;
let name: String = pane_table.get("name")?;
let title: String = pane_table.get("title")?;
let y_unit: String = pane_table.get("y_unit")?;
let refresh_interval: u32 = pane_table.get("refresh_interval")?;
if let Ok(callback) = pane_table.get::<Function>("callback") {
let callback_key = lua.create_registry_value(callback)?;
refresh_callbacks.insert(name.clone(), callback_key);
}
pane_types.push(CustomChartConfig {
name,
title,
y_unit: if y_unit.is_empty() {
None
} else {
Some(y_unit)
},
refresh_interval,
plugin_name: plugin_name.to_string(),
});
}
Ok(pane_types)
}
fn extract_stat_pane_types(
lua: &Lua,
plugin_name: &str,
refresh_callbacks: &mut FxHashMap<String, RegistryKey>,
) -> LuaResult<Vec<StatPaneConfig>> {
let globals = lua.globals();
let enya: Table = globals.get("enya")?;
let registered: Table = enya.get("_registered_stat_panes")?;
let mut pane_types = Vec::new();
for pair in registered.pairs::<i64, Table>() {
let (_, pane_table) = pair?;
let name: String = pane_table.get("name")?;
let title: String = pane_table.get("title")?;
let unit: String = pane_table.get("unit")?;
let refresh_interval: u32 = pane_table.get("refresh_interval")?;
if let Ok(callback) = pane_table.get::<Function>("callback") {
let callback_key = lua.create_registry_value(callback)?;
refresh_callbacks.insert(name.clone(), callback_key);
}
pane_types.push(StatPaneConfig {
name,
title,
unit: if unit.is_empty() { None } else { Some(unit) },
refresh_interval,
plugin_name: plugin_name.to_string(),
});
}
Ok(pane_types)
}
fn extract_gauge_pane_types(
lua: &Lua,
plugin_name: &str,
refresh_callbacks: &mut FxHashMap<String, RegistryKey>,
) -> LuaResult<Vec<GaugePaneConfig>> {
let globals = lua.globals();
let enya: Table = globals.get("enya")?;
let registered: Table = enya.get("_registered_gauge_panes")?;
let mut pane_types = Vec::new();
for pair in registered.pairs::<i64, Table>() {
let (_, pane_table) = pair?;
let name: String = pane_table.get("name")?;
let title: String = pane_table.get("title")?;
let unit: String = pane_table.get("unit")?;
let min: f64 = pane_table.get("min")?;
let max: f64 = pane_table.get("max")?;
let refresh_interval: u32 = pane_table.get("refresh_interval")?;
if let Ok(callback) = pane_table.get::<Function>("callback") {
let callback_key = lua.create_registry_value(callback)?;
refresh_callbacks.insert(name.clone(), callback_key);
}
pane_types.push(GaugePaneConfig {
name,
title,
unit: if unit.is_empty() { None } else { Some(unit) },
min_scaled: (min * 1_000_000.0) as i64,
max_scaled: (max * 1_000_000.0) as i64,
refresh_interval,
plugin_name: plugin_name.to_string(),
});
}
Ok(pane_types)
}
fn extract_lifecycle_hooks(lua: &Lua) -> (Option<RegistryKey>, Option<RegistryKey>) {
let globals = lua.globals();
let on_activate_key = globals
.get::<Function>("on_activate")
.ok()
.and_then(|f| lua.create_registry_value(f).ok());
let on_deactivate_key = globals
.get::<Function>("on_deactivate")
.ok()
.and_then(|f| lua.create_registry_value(f).ok());
(on_activate_key, on_deactivate_key)
}
fn extract_theme(lua: &Lua) -> Option<ThemeDefinition> {
let globals = lua.globals();
let theme_table: Table = globals.get("theme").ok()?;
let name: String = theme_table.get("name").ok()?;
let display_name: String = theme_table
.get::<Option<String>>("display_name")
.ok()
.flatten()
.unwrap_or_else(|| name.clone());
let base_str: String = theme_table
.get::<Option<String>>("base")
.ok()
.flatten()
.unwrap_or_else(|| "dark".to_string());
let base = ThemeBase::parse(&base_str);
let colors = Self::extract_theme_colors(lua, &theme_table);
Some(ThemeDefinition {
name,
display_name,
base,
colors,
})
}
fn extract_theme_colors(_lua: &Lua, theme_table: &Table) -> ThemeColors {
let colors_table: Option<Table> = theme_table.get("colors").ok();
let mut colors = ThemeColors::default();
let Some(ct) = colors_table else {
return colors;
};
let parse_color = |key: &str| -> Option<u32> {
ct.get::<Option<String>>(key)
.ok()
.flatten()
.and_then(|s| ThemeColors::parse_hex(&s))
};
colors.bg_base = parse_color("bg_base");
colors.bg_surface = parse_color("bg_surface");
colors.bg_elevated = parse_color("bg_elevated");
colors.text_primary = parse_color("text_primary");
colors.text_secondary = parse_color("text_secondary");
colors.text_muted = parse_color("text_muted");
colors.accent_primary = parse_color("accent_primary");
colors.accent_hover = parse_color("accent_hover");
colors.accent_muted = parse_color("accent_muted");
colors.border_subtle = parse_color("border_subtle");
colors.border_strong = parse_color("border_strong");
colors.success = parse_color("success");
colors.warning = parse_color("warning");
colors.error = parse_color("error");
colors.info = parse_color("info");
if let Ok(Some(chart_table)) = ct.get::<Option<Table>>("chart") {
let mut palette = Vec::new();
for (_, hex) in chart_table.pairs::<i64, String>().flatten() {
if let Some(color) = ThemeColors::parse_hex(&hex) {
palette.push(color);
}
}
colors.chart_palette = palette;
}
colors
}
fn setup_runtime_api<'lua, 'scope>(
lua: &'lua Lua,
scope: &'lua mlua::Scope<'lua, 'scope>,
ctx: &'scope PluginContext,
) -> LuaResult<()>
where
'scope: 'lua,
{
let globals = lua.globals();
let enya: Table = globals.get("enya")?;
let notify_fn = scope.create_function(|_, (level, msg): (String, String)| {
ctx.notify(&level, &msg);
Ok(())
})?;
enya.set("notify", notify_fn)?;
let log_fn = scope.create_function(|_, (level, msg): (String, String)| {
let log_level = LogLevel::parse(&level);
ctx.log(log_level, &msg);
Ok(())
})?;
enya.set("log", log_fn)?;
let repaint_fn = scope.create_function(|_, ()| {
ctx.request_repaint();
Ok(())
})?;
enya.set("request_repaint", repaint_fn)?;
let version = ctx.editor_version();
let version_fn = scope.create_function(move |_, ()| Ok(version.to_string()))?;
enya.set("editor_version", version_fn)?;
let is_wasm = ctx.is_wasm();
let wasm_fn = scope.create_function(move |_, ()| Ok(is_wasm))?;
enya.set("is_wasm", wasm_fn)?;
let theme_name = ctx.theme_name();
let theme_fn = scope.create_function(move |_, ()| Ok(theme_name.to_string()))?;
enya.set("theme_name", theme_fn)?;
let clipboard_write_fn =
scope.create_function(|_, text: String| Ok(ctx.clipboard_write(&text)))?;
enya.set("clipboard_write", clipboard_write_fn)?;
let clipboard_read_fn = scope.create_function(|_, ()| Ok(ctx.clipboard_read()))?;
enya.set("clipboard_read", clipboard_read_fn)?;
let execute_fn = scope.create_function(|_, (cmd, args): (String, Option<String>)| {
let args_str = args.as_deref().unwrap_or("");
log::info!("[lua] Execute request: {cmd} {args_str}");
Ok(true)
})?;
enya.set("execute", execute_fn)?;
let http_get_fn =
scope.create_function(|lua, (url, headers): (String, Option<Table>)| {
use rustc_hash::FxHashMap;
let mut header_map = FxHashMap::default();
if let Some(h) = headers {
for pair in h.pairs::<String, String>() {
let (k, v) = pair?;
header_map.insert(k, v);
}
}
let result = ctx.http_get(&url, &header_map);
let response_table = lua.create_table()?;
match result {
Ok(resp) => {
response_table.set("status", resp.status)?;
response_table.set("body", resp.body)?;
let headers_table = lua.create_table()?;
for (k, v) in resp.headers {
headers_table.set(k, v)?;
}
response_table.set("headers", headers_table)?;
}
Err(e) => {
response_table.set("error", e.message)?;
}
}
Ok(response_table)
})?;
enya.set("http_get", http_get_fn)?;
let http_post_fn = scope.create_function(
|lua, (url, body, headers): (String, String, Option<Table>)| {
use rustc_hash::FxHashMap;
let mut header_map = FxHashMap::default();
if let Some(h) = headers {
for pair in h.pairs::<String, String>() {
let (k, v) = pair?;
header_map.insert(k, v);
}
}
let result = ctx.http_post(&url, &body, &header_map);
let response_table = lua.create_table()?;
match result {
Ok(resp) => {
response_table.set("status", resp.status)?;
response_table.set("body", resp.body)?;
let headers_table = lua.create_table()?;
for (k, v) in resp.headers {
headers_table.set(k, v)?;
}
response_table.set("headers", headers_table)?;
}
Err(e) => {
response_table.set("error", e.message)?;
}
}
Ok(response_table)
},
)?;
enya.set("http_post", http_post_fn)?;
let add_query_pane_fn =
scope.create_function(|_, (query, title): (String, Option<String>)| {
ctx.add_query_pane(&query, title.as_deref());
Ok(())
})?;
enya.set("add_query_pane", add_query_pane_fn)?;
let add_logs_pane_fn = scope.create_function(|_, ()| {
ctx.add_logs_pane();
Ok(())
})?;
enya.set("add_logs_pane", add_logs_pane_fn)?;
let add_tracing_pane_fn = scope.create_function(|_, trace_id: Option<String>| {
ctx.add_tracing_pane(trace_id.as_deref());
Ok(())
})?;
enya.set("add_tracing_pane", add_tracing_pane_fn)?;
let add_terminal_pane_fn = scope.create_function(|_, ()| {
ctx.add_terminal_pane();
Ok(())
})?;
enya.set("add_terminal_pane", add_terminal_pane_fn)?;
let add_sql_pane_fn = scope.create_function(|_, ()| {
ctx.add_sql_pane();
Ok(())
})?;
enya.set("add_sql_pane", add_sql_pane_fn)?;
let close_pane_fn = scope.create_function(|_, ()| {
ctx.close_focused_pane();
Ok(())
})?;
enya.set("close_pane", close_pane_fn)?;
let focus_pane_fn = scope.create_function(|_, direction: String| {
ctx.focus_pane(&direction);
Ok(())
})?;
enya.set("focus_pane", focus_pane_fn)?;
let set_time_range_fn = scope.create_function(|_, preset: String| {
ctx.set_time_range_preset(&preset);
Ok(())
})?;
enya.set("set_time_range", set_time_range_fn)?;
let set_time_range_absolute_fn = scope.create_function(|_, (start, end): (f64, f64)| {
ctx.set_time_range_absolute(start, end);
Ok(())
})?;
enya.set("set_time_range_absolute", set_time_range_absolute_fn)?;
let get_time_range_fn = scope.create_function(|lua, ()| {
let (start, end) = ctx.get_time_range();
let table = lua.create_table()?;
table.set("start", start)?;
table.set("end", end)?;
Ok(table)
})?;
enya.set("get_time_range", get_time_range_fn)?;
let get_focused_pane_fn = scope.create_function(|lua, ()| {
if let Some(info) = ctx.get_focused_pane_info() {
let table = lua.create_table()?;
table.set("pane_type", info.pane_type)?;
if let Some(title) = info.title {
table.set("title", title)?;
}
if let Some(query) = info.query {
table.set("query", query)?;
}
if let Some(metric_name) = info.metric_name {
table.set("metric_name", metric_name)?;
}
Ok(mlua::Value::Table(table))
} else {
Ok(mlua::Value::Nil)
}
})?;
enya.set("get_focused_pane", get_focused_pane_fn)?;
let add_custom_pane_fn = scope.create_function(|_, pane_type: String| {
ctx.add_custom_table_pane(&pane_type);
Ok(())
})?;
enya.set("add_custom_pane", add_custom_pane_fn)?;
let set_pane_data_fn = scope.create_function(|_, (pane_type, data): (String, Table)| {
use crate::{CustomTableData, CustomTableRow};
if let Ok(Some(error)) = data.get::<Option<String>>("error") {
let table_data = CustomTableData::with_error(error);
ctx.update_custom_table_data_by_type(&pane_type, table_data);
return Ok(());
}
let mut rows = Vec::new();
if let Ok(rows_table) = data.get::<Table>("rows") {
for (_, row_table) in rows_table.pairs::<i64, Table>().flatten() {
let mut row = CustomTableRow::new();
for (key, value) in row_table.pairs::<String, String>().flatten() {
row = row.with_cell(key, value);
}
rows.push(row);
}
}
let table_data = CustomTableData::with_rows(rows);
ctx.update_custom_table_data_by_type(&pane_type, table_data);
Ok(())
})?;
enya.set("set_pane_data", set_pane_data_fn)?;
let add_chart_pane_fn = scope.create_function(|_, pane_type: String| {
ctx.add_custom_chart_pane(&pane_type);
Ok(())
})?;
enya.set("add_chart_pane", add_chart_pane_fn)?;
let set_chart_data_fn =
scope.create_function(|_, (pane_type, data): (String, Table)| {
use crate::{ChartDataPoint, ChartSeries, CustomChartData};
if let Ok(Some(error)) = data.get::<Option<String>>("error") {
let chart_data = CustomChartData::with_error(error);
ctx.update_custom_chart_data_by_type(&pane_type, chart_data);
return Ok(());
}
let mut series_vec = Vec::new();
if let Ok(series_table) = data.get::<Table>("series") {
for (_, series_entry) in series_table.pairs::<i64, Table>().flatten() {
let name: String = series_entry
.get::<Option<String>>("name")?
.unwrap_or_else(|| "unnamed".to_string());
let mut series = ChartSeries::new(name);
if let Ok(tags_table) = series_entry.get::<Table>("tags") {
for (key, value) in tags_table.pairs::<String, String>().flatten() {
series = series.with_tag(key, value);
}
}
if let Ok(points_table) = series_entry.get::<Table>("points") {
for (_, point_entry) in points_table.pairs::<i64, Table>().flatten() {
let timestamp: f64 = point_entry.get("timestamp")?;
let value: f64 = point_entry.get("value")?;
series.points.push(ChartDataPoint::new(timestamp, value));
}
}
series_vec.push(series);
}
}
let chart_data = CustomChartData::with_series(series_vec);
ctx.update_custom_chart_data_by_type(&pane_type, chart_data);
Ok(())
})?;
enya.set("set_chart_data", set_chart_data_fn)?;
let add_stat_pane_fn = scope.create_function(|_, pane_type: String| {
ctx.add_stat_pane(&pane_type);
Ok(())
})?;
enya.set("add_stat_pane", add_stat_pane_fn)?;
let set_stat_data_fn = scope.create_function(|_, (pane_type, data): (String, Table)| {
use crate::{StatPaneData, ThresholdConfig};
if let Ok(Some(error)) = data.get::<Option<String>>("error") {
let stat_data = StatPaneData::with_error(error);
ctx.update_stat_data_by_type(&pane_type, stat_data);
return Ok(());
}
let value: f64 = data.get::<Option<f64>>("value")?.unwrap_or(0.0);
let mut stat_data = StatPaneData::with_value(value);
if let Ok(sparkline_table) = data.get::<Table>("sparkline") {
let mut sparkline = Vec::new();
for (_, val) in sparkline_table.pairs::<i64, f64>().flatten() {
sparkline.push(val);
}
stat_data.sparkline = sparkline;
}
if let Ok(Some(change_value)) = data.get::<Option<f64>>("change_value") {
let change_period: String = data
.get::<Option<String>>("change_period")?
.unwrap_or_else(|| "vs last period".to_string());
stat_data.change_value = Some(change_value);
stat_data.change_period = Some(change_period);
}
if let Ok(thresholds_table) = data.get::<Table>("thresholds") {
for (_, thresh) in thresholds_table.pairs::<i64, Table>().flatten() {
let value: f64 = thresh.get("value")?;
let color: String = thresh.get("color")?;
let label: Option<String> = thresh.get::<Option<String>>("label")?;
let mut threshold = ThresholdConfig::new(value, color);
if let Some(lbl) = label {
threshold = threshold.with_label(lbl);
}
stat_data.thresholds.push(threshold);
}
}
ctx.update_stat_data_by_type(&pane_type, stat_data);
Ok(())
})?;
enya.set("set_stat_data", set_stat_data_fn)?;
let add_gauge_pane_fn = scope.create_function(|_, pane_type: String| {
ctx.add_gauge_pane(&pane_type);
Ok(())
})?;
enya.set("add_gauge_pane", add_gauge_pane_fn)?;
let set_gauge_data_fn =
scope.create_function(|_, (pane_type, data): (String, Table)| {
use crate::{GaugePaneData, ThresholdConfig};
if let Ok(Some(error)) = data.get::<Option<String>>("error") {
let gauge_data = GaugePaneData::with_error(error);
ctx.update_gauge_data_by_type(&pane_type, gauge_data);
return Ok(());
}
let value: f64 = data.get::<Option<f64>>("value")?.unwrap_or(0.0);
let mut gauge_data = GaugePaneData::with_value(value);
if let Ok(thresholds_table) = data.get::<Table>("thresholds") {
for (_, thresh) in thresholds_table.pairs::<i64, Table>().flatten() {
let value: f64 = thresh.get("value")?;
let color: String = thresh.get("color")?;
let label: Option<String> = thresh.get::<Option<String>>("label")?;
let mut threshold = ThresholdConfig::new(value, color);
if let Some(lbl) = label {
threshold = threshold.with_label(lbl);
}
gauge_data.thresholds.push(threshold);
}
}
ctx.update_gauge_data_by_type(&pane_type, gauge_data);
Ok(())
})?;
enya.set("set_gauge_data", set_gauge_data_fn)?;
Ok(())
}
fn call_lifecycle_hook(
&self,
hook_key: &Option<RegistryKey>,
ctx: &PluginContext,
) -> PluginResult<()> {
let Some(key) = hook_key else {
return Ok(());
};
let lua = self.lua.lock();
lua.scope(|scope| {
Self::setup_runtime_api(&lua, scope, ctx)?;
let hook: Function = lua.registry_value(key)?;
hook.call::<()>(())?;
Ok(())
})
.map_err(|e| PluginError::OperationFailed(format!("Lifecycle hook failed: {e}")))
}
}
impl Plugin for LuaPlugin {
fn name(&self) -> &'static str {
self.name
}
fn version(&self) -> &'static str {
self.version
}
fn description(&self) -> &'static str {
self.description
}
fn capabilities(&self) -> PluginCapabilities {
let mut caps = PluginCapabilities::empty();
if !self.commands.is_empty() {
caps |= PluginCapabilities::COMMANDS;
}
if !self.keybindings.is_empty() {
caps |= PluginCapabilities::KEYBOARD;
}
if self.theme.is_some() {
caps |= PluginCapabilities::CUSTOM_THEMES;
}
if !self.table_pane_types.is_empty()
|| !self.chart_pane_types.is_empty()
|| !self.stat_pane_types.is_empty()
|| !self.gauge_pane_types.is_empty()
{
caps |= PluginCapabilities::PANES;
}
caps
}
fn init(&mut self, _ctx: &PluginContext) -> PluginResult<()> {
log::info!(
"[plugin:{}] Lua plugin loaded from {}",
self.name,
self.path.display()
);
Ok(())
}
fn activate(&mut self, ctx: &PluginContext) -> PluginResult<()> {
self.active = true;
if self.on_activate_key.is_some() {
self.call_lifecycle_hook(&self.on_activate_key, ctx)?;
}
log::info!("[plugin:{}] Activated", self.name);
Ok(())
}
fn deactivate(&mut self, ctx: &PluginContext) -> PluginResult<()> {
if self.on_deactivate_key.is_some() {
self.call_lifecycle_hook(&self.on_deactivate_key, ctx)?;
}
self.active = false;
log::info!("[plugin:{}] Deactivated", self.name);
Ok(())
}
fn commands(&self) -> Vec<CommandConfig> {
self.commands
.iter()
.map(|c| CommandConfig {
name: c.name.clone(),
aliases: c.aliases.clone(),
description: c.description.clone(),
accepts_args: c.accepts_args,
})
.collect()
}
fn keybindings(&self) -> Vec<KeybindingConfig> {
self.keybindings.clone()
}
fn themes(&self) -> Vec<ThemeDefinition> {
self.theme.clone().into_iter().collect()
}
fn custom_table_panes(&self) -> Vec<crate::CustomTableConfig> {
self.table_pane_types.clone()
}
fn custom_chart_panes(&self) -> Vec<crate::CustomChartConfig> {
self.chart_pane_types.clone()
}
fn custom_stat_panes(&self) -> Vec<crate::StatPaneConfig> {
self.stat_pane_types.clone()
}
fn custom_gauge_panes(&self) -> Vec<crate::GaugePaneConfig> {
self.gauge_pane_types.clone()
}
fn refreshable_pane_types(&self) -> Vec<(&str, u32)> {
let mut result = Vec::new();
for config in &self.table_pane_types {
if config.refresh_interval > 0 && self.refresh_callbacks.contains_key(&config.name) {
result.push((config.name.as_str(), config.refresh_interval));
}
}
for config in &self.chart_pane_types {
if config.refresh_interval > 0 && self.refresh_callbacks.contains_key(&config.name) {
result.push((config.name.as_str(), config.refresh_interval));
}
}
for config in &self.stat_pane_types {
if config.refresh_interval > 0 && self.refresh_callbacks.contains_key(&config.name) {
result.push((config.name.as_str(), config.refresh_interval));
}
}
for config in &self.gauge_pane_types {
if config.refresh_interval > 0 && self.refresh_callbacks.contains_key(&config.name) {
result.push((config.name.as_str(), config.refresh_interval));
}
}
result
}
fn trigger_pane_refresh(&mut self, pane_type: &str, ctx: &PluginContext) -> bool {
let Some(callback_key) = self.refresh_callbacks.get(pane_type) else {
log::warn!(
"[plugin:{}] No refresh callback for pane type '{}'",
self.name,
pane_type
);
return false;
};
let lua = self.lua.lock();
let result = lua.scope(|scope| {
Self::setup_runtime_api(&lua, scope, ctx)?;
let callback: Function = lua.registry_value(callback_key)?;
callback.call::<()>(())?;
Ok(())
});
match result {
Ok(()) => {
log::debug!("[plugin:{}] Refreshed pane type '{}'", self.name, pane_type);
true
}
Err(e) => {
log::error!(
"[plugin:{}] Error refreshing pane type '{}': {e}",
self.name,
pane_type
);
ctx.notify("error", &format!("Plugin refresh error: {e}"));
false
}
}
}
fn execute_command(&mut self, command: &str, args: &str, ctx: &PluginContext) -> bool {
let cmd = self
.commands
.iter()
.find(|c| c.name == command || c.aliases.contains(&command.to_string()));
let Some(cmd) = cmd else {
return false;
};
let lua = self.lua.lock();
let result = lua.scope(|scope| {
Self::setup_runtime_api(&lua, scope, ctx)?;
let callback: Function = lua.registry_value(&cmd.callback_key)?;
let success: bool = callback.call(args)?;
Ok(success)
});
match result {
Ok(success) => success,
Err(e) => {
log::error!(
"[plugin:{}] Error executing command '{}': {e}",
self.name,
command
);
ctx.notify("error", &format!("Plugin error: {e}"));
false
}
}
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
pub const EXAMPLE_LUA_PLUGIN: &str = r#"-- Example Enya Lua Plugin
-- Place this file in ~/.enya/plugins/
-- Plugin metadata (required)
plugin = {
name = "example-lua",
version = "0.1.0",
description = "An example Lua plugin showing available features"
}
-- Register a simple command
enya.register_command("lua-hello", {
description = "Say hello from Lua",
aliases = {"lhello"},
accepts_args = true
}, function(args)
if args == "" then
enya.notify("info", "Hello from Lua!")
else
enya.notify("info", "Hello, " .. args .. "!")
end
return true
end)
-- Register a command with conditional logic
enya.register_command("lua-check", {
description = "Check something with conditional logic",
accepts_args = true
}, function(args)
local num = tonumber(args)
if num == nil then
enya.notify("error", "Please provide a number")
return false
end
if num > 100 then
enya.notify("warn", "That's a large number: " .. tostring(num))
elseif num < 0 then
enya.notify("error", "Negative numbers not allowed")
return false
else
enya.notify("info", "Number " .. tostring(num) .. " is valid")
end
return true
end)
-- Register keybindings
enya.keymap("Space+l+h", "lua-hello", "Lua hello")
enya.keymap("Space+l+c", "lua-check", "Lua check")
-- Lifecycle hooks (optional)
function on_activate()
enya.log("info", "Example Lua plugin activated!")
end
function on_deactivate()
enya.log("info", "Example Lua plugin deactivated!")
end
"#;
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_load_simple_plugin() {
let source = r#"
plugin = {
name = "test-plugin",
version = "1.0.0",
description = "A test plugin"
}
enya.register_command("test-cmd", {
description = "Test command",
aliases = {"tc"},
accepts_args = false
}, function(args)
return true
end)
"#;
let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
assert_eq!(plugin.name(), "test-plugin");
assert_eq!(plugin.version(), "1.0.0");
assert_eq!(plugin.description(), "A test plugin");
assert_eq!(plugin.commands().len(), 1);
assert_eq!(plugin.commands()[0].name, "test-cmd");
}
#[test]
fn test_keybindings() {
let source = r#"
plugin = { name = "kb-test" }
enya.register_command("my-cmd", {}, function() return true end)
enya.keymap("Space+t+t", "my-cmd", "Test binding", {"normal"})
"#;
let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
assert_eq!(plugin.keybindings().len(), 1);
assert_eq!(plugin.keybindings()[0].keys, "Space+t+t");
assert_eq!(plugin.keybindings()[0].command, "my-cmd");
assert_eq!(plugin.keybindings()[0].modes, vec!["normal"]);
}
#[test]
fn test_missing_metadata() {
let source = r#"
-- No plugin table defined
enya.register_command("test", {}, function() return true end)
"#;
let result = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua"));
assert!(result.is_err());
}
#[test]
fn test_syntax_error() {
let source = r#"
plugin = { name = "broken"
-- Missing closing brace
"#;
let result = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua"));
assert!(result.is_err());
}
#[test]
fn test_theme_plugin() {
let source = r##"
plugin = {
name = "tokyo-night-theme",
version = "1.0.0",
description = "Tokyo Night color theme"
}
theme = {
name = "tokyo-night",
display_name = "Tokyo Night",
base = "dark",
colors = {
bg_base = "#1a1b26",
bg_surface = "#24283b",
accent_primary = "#7aa2f7",
accent_hover = "#89b4fa",
success = "#9ece6a",
error = "#f7768e",
chart = {
"#7aa2f7",
"#9ece6a",
"#e0af68",
"#f7768e",
}
}
}
"##;
let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
assert_eq!(plugin.name(), "tokyo-night-theme");
assert!(
plugin
.capabilities()
.contains(PluginCapabilities::CUSTOM_THEMES)
);
let themes = plugin.themes();
assert_eq!(themes.len(), 1);
let theme = &themes[0];
assert_eq!(theme.name, "tokyo-night");
assert_eq!(theme.display_name, "Tokyo Night");
assert_eq!(theme.base, ThemeBase::Dark);
assert_eq!(theme.colors.bg_base, Some(0x1a1b26));
assert_eq!(theme.colors.bg_surface, Some(0x24283b));
assert_eq!(theme.colors.accent_primary, Some(0x7aa2f7));
assert_eq!(theme.colors.success, Some(0x9ece6a));
assert_eq!(theme.colors.chart_palette.len(), 4);
}
#[test]
fn test_theme_with_light_base() {
let source = r##"
plugin = { name = "light-theme" }
theme = {
name = "my-light",
base = "light",
colors = {
bg_base = "#ffffff",
text_primary = "#000000"
}
}
"##;
let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
let themes = plugin.themes();
assert_eq!(themes.len(), 1);
assert_eq!(themes[0].base, ThemeBase::Light);
assert_eq!(themes[0].colors.bg_base, Some(0xffffff));
assert_eq!(themes[0].colors.text_primary, Some(0x000000));
}
#[test]
fn test_plugin_without_theme() {
let source = r#"
plugin = { name = "no-theme" }
enya.register_command("test", {}, function() return true end)
"#;
let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
assert!(
!plugin
.capabilities()
.contains(PluginCapabilities::CUSTOM_THEMES)
);
assert!(plugin.themes().is_empty());
}
}