Skip to main content

enya_plugin/
lua.rs

1//! Lua plugin support using mlua.
2//!
3//! This module enables writing plugins in Lua for dynamic behavior beyond
4//! what TOML config plugins allow. Lua plugins can have conditional logic,
5//! access editor state, and create complex workflows.
6//!
7//! # Example Lua Plugin
8//!
9//! ```lua
10//! -- ~/.enya/plugins/my-plugin.lua
11//!
12//! -- Plugin metadata (required)
13//! plugin = {
14//!     name = "my-lua-plugin",
15//!     version = "0.1.0",
16//!     description = "A Lua plugin example"
17//! }
18//!
19//! -- Register a command
20//! enya.register_command("greet", {
21//!     description = "Greet the user",
22//!     aliases = {"hello", "hi"},
23//!     accepts_args = true
24//! }, function(args)
25//!     if args == "" then
26//!         enya.notify("info", "Hello, World!")
27//!     else
28//!         enya.notify("info", "Hello, " .. args .. "!")
29//!     end
30//!     return true
31//! end)
32//!
33//! -- Register a keybinding
34//! enya.keymap("Space+x+g", "greet", "Greet user")
35//!
36//! -- Lifecycle hooks (optional)
37//! function on_activate()
38//!     enya.log("info", "Plugin activated!")
39//! end
40//!
41//! function on_deactivate()
42//!     enya.log("info", "Plugin deactivated!")
43//! end
44//! ```
45//!
46//! # Available API
47//!
48//! ## Registration Functions (available during load)
49//!
50//! - `enya.register_command(name, config, callback)` - Register a command
51//! - `enya.keymap(keys, command, description, [modes])` - Register a keybinding
52//!
53//! ## Runtime Functions (available in callbacks)
54//!
55//! - `enya.notify(level, message)` - Show notification ("info", "warn", "error")
56//! - `enya.log(level, message)` - Log a message
57//! - `enya.request_repaint()` - Request UI refresh
58//! - `enya.editor_version()` - Get editor version string
59//! - `enya.is_wasm()` - Check if running in WASM
60//! - `enya.theme_name()` - Get current theme name
61//! - `enya.clipboard_write(text)` - Write text to clipboard
62//! - `enya.clipboard_read()` - Read text from clipboard (returns nil if empty)
63//! - `enya.execute(command, [args])` - Execute another command
64//! - `enya.http_get(url, [headers])` - HTTP GET, returns `{status, body, headers}` or `{error}`
65//! - `enya.http_post(url, body, [headers])` - HTTP POST, returns `{status, body, headers}` or `{error}`
66//! - `enya.get_focused_pane()` - Get focused pane info `{pane_type, title, query, metric_name}` or nil
67
68use std::any::Any;
69use std::path::{Path, PathBuf};
70
71use mlua::{Function, Lua, RegistryKey, Result as LuaResult, Table, Value, Variadic};
72use parking_lot::Mutex;
73use rustc_hash::FxHashMap;
74
75use crate::theme::{ThemeBase, ThemeColors, ThemeDefinition};
76use crate::traits::{CommandConfig, KeybindingConfig, Plugin, PluginCapabilities};
77use crate::types::{
78    CustomChartConfig, CustomTableConfig, GaugePaneConfig, LogLevel, PluginContext, StatPaneConfig,
79    TableColumnConfig,
80};
81use crate::{PluginError, PluginResult};
82
83/// A command registered by a Lua plugin.
84struct LuaCommand {
85    /// Command name
86    name: String,
87    /// Aliases for the command
88    aliases: Vec<String>,
89    /// Description
90    description: String,
91    /// Whether the command accepts arguments
92    accepts_args: bool,
93    /// Registry key for the callback function
94    callback_key: RegistryKey,
95}
96
97/// Set up no-op placeholder functions on the enya table.
98/// These are needed because plugins may call API functions during initial loading,
99/// before the real implementations are available via setup_runtime_api.
100fn setup_noop_api(lua: &Lua, enya: &Table) -> LuaResult<()> {
101    // Core functions
102    enya.set(
103        "notify",
104        lua.create_function(|_, _: (String, String)| Ok(()))?,
105    )?;
106    enya.set("log", lua.create_function(|_, _: (String, String)| Ok(()))?)?;
107    enya.set("request_repaint", lua.create_function(|_, ()| Ok(()))?)?;
108    enya.set(
109        "editor_version",
110        lua.create_function(|_, ()| Ok("unknown".to_string()))?,
111    )?;
112    enya.set("is_wasm", lua.create_function(|_, ()| Ok(false))?)?;
113    enya.set(
114        "theme_name",
115        lua.create_function(|_, ()| Ok("unknown".to_string()))?,
116    )?;
117    enya.set(
118        "clipboard_write",
119        lua.create_function(|_, _: String| Ok(false))?,
120    )?;
121    enya.set(
122        "clipboard_read",
123        lua.create_function(|_, ()| Ok(None::<String>))?,
124    )?;
125    enya.set(
126        "execute",
127        lua.create_function(|_, _: (String, Option<String>)| Ok(false))?,
128    )?;
129
130    // HTTP functions - return error during loading phase
131    enya.set(
132        "http_get",
133        lua.create_function(|_, _: (String, Option<Table>)| -> LuaResult<Table> {
134            Err(mlua::Error::runtime(
135                "HTTP not available during plugin loading",
136            ))
137        })?,
138    )?;
139    enya.set(
140        "http_post",
141        lua.create_function(
142            |_, _: (String, String, Option<Table>)| -> LuaResult<Table> {
143                Err(mlua::Error::runtime(
144                    "HTTP not available during plugin loading",
145                ))
146            },
147        )?,
148    )?;
149
150    // Pane management
151    enya.set(
152        "add_query_pane",
153        lua.create_function(|_, _: (String, Option<String>)| Ok(()))?,
154    )?;
155    enya.set("add_logs_pane", lua.create_function(|_, ()| Ok(()))?)?;
156    enya.set(
157        "add_tracing_pane",
158        lua.create_function(|_, _: Option<String>| Ok(()))?,
159    )?;
160    enya.set("add_terminal_pane", lua.create_function(|_, ()| Ok(()))?)?;
161    enya.set("add_sql_pane", lua.create_function(|_, ()| Ok(()))?)?;
162    enya.set("close_pane", lua.create_function(|_, ()| Ok(()))?)?;
163    enya.set("focus_pane", lua.create_function(|_, _: String| Ok(()))?)?;
164
165    // Time range
166    enya.set(
167        "set_time_range",
168        lua.create_function(|_, _: String| Ok(()))?,
169    )?;
170    enya.set(
171        "set_time_range_absolute",
172        lua.create_function(|_, _: (f64, f64)| Ok(()))?,
173    )?;
174    enya.set(
175        "get_time_range",
176        lua.create_function(|lua, ()| {
177            let table = lua.create_table()?;
178            table.set("start", 0.0)?;
179            table.set("end", 0.0)?;
180            Ok(table)
181        })?,
182    )?;
183    enya.set(
184        "get_focused_pane",
185        lua.create_function(|_, ()| Ok(None::<Table>))?,
186    )?;
187
188    // Custom pane functions
189    enya.set(
190        "add_custom_pane",
191        lua.create_function(|_, _: String| Ok(()))?,
192    )?;
193    enya.set(
194        "set_pane_data",
195        lua.create_function(|_, _: (String, Table)| Ok(()))?,
196    )?;
197    enya.set(
198        "add_chart_pane",
199        lua.create_function(|_, _: String| Ok(()))?,
200    )?;
201    enya.set(
202        "set_chart_data",
203        lua.create_function(|_, _: (String, Table)| Ok(()))?,
204    )?;
205    enya.set("add_stat_pane", lua.create_function(|_, _: String| Ok(()))?)?;
206    enya.set(
207        "set_stat_data",
208        lua.create_function(|_, _: (String, Table)| Ok(()))?,
209    )?;
210    enya.set(
211        "add_gauge_pane",
212        lua.create_function(|_, _: String| Ok(()))?,
213    )?;
214    enya.set(
215        "set_gauge_data",
216        lua.create_function(|_, _: (String, Table)| Ok(()))?,
217    )?;
218
219    Ok(())
220}
221
222/// A Lua-based plugin.
223pub struct LuaPlugin {
224    /// The Lua state (wrapped in Mutex for Sync)
225    lua: Mutex<Lua>,
226    /// Plugin name (leaked for 'static lifetime)
227    name: &'static str,
228    /// Plugin version (leaked for 'static lifetime)
229    version: &'static str,
230    /// Plugin description (leaked for 'static lifetime)
231    description: &'static str,
232    /// Commands registered by this plugin
233    commands: Vec<LuaCommand>,
234    /// Keybindings registered by this plugin
235    keybindings: Vec<KeybindingConfig>,
236    /// Custom table pane types registered by this plugin
237    table_pane_types: Vec<CustomTableConfig>,
238    /// Custom chart pane types registered by this plugin
239    chart_pane_types: Vec<CustomChartConfig>,
240    /// Custom stat pane types registered by this plugin
241    stat_pane_types: Vec<StatPaneConfig>,
242    /// Custom gauge pane types registered by this plugin
243    gauge_pane_types: Vec<GaugePaneConfig>,
244    /// Refresh callbacks keyed by pane type name
245    refresh_callbacks: FxHashMap<String, RegistryKey>,
246    /// Custom theme defined by this plugin (if any)
247    theme: Option<ThemeDefinition>,
248    /// Path to the plugin file
249    path: PathBuf,
250    /// Whether the plugin is active
251    active: bool,
252    /// Registry key for on_activate hook (if defined)
253    on_activate_key: Option<RegistryKey>,
254    /// Registry key for on_deactivate hook (if defined)
255    on_deactivate_key: Option<RegistryKey>,
256}
257
258impl LuaPlugin {
259    /// Load a Lua plugin from a file.
260    pub fn load(path: &Path) -> PluginResult<Self> {
261        let content = std::fs::read_to_string(path).map_err(|e| {
262            PluginError::InitializationFailed(format!("Failed to read {}: {e}", path.display()))
263        })?;
264
265        Self::load_from_source(&content, path)
266    }
267
268    /// Load a Lua plugin from source code.
269    pub fn load_from_source(source: &str, path: &Path) -> PluginResult<Self> {
270        let lua = Lua::new();
271
272        // Set up the initial enya table with registration functions
273        Self::setup_registration_api(&lua).map_err(|e| {
274            PluginError::InitializationFailed(format!("Failed to set up Lua API: {e}"))
275        })?;
276
277        // Execute the plugin script
278        lua.load(source)
279            .set_name(path.to_string_lossy())
280            .exec()
281            .map_err(|e| {
282                PluginError::InitializationFailed(format!(
283                    "Failed to execute {}: {e}",
284                    path.display()
285                ))
286            })?;
287
288        // Extract plugin metadata
289        let (name, version, description) = Self::extract_metadata(&lua).map_err(|e| {
290            PluginError::InvalidConfiguration(format!(
291                "Failed to read plugin metadata from {}: {e}",
292                path.display()
293            ))
294        })?;
295
296        // Extract registered commands
297        let commands = Self::extract_commands(&lua).map_err(|e| {
298            PluginError::InvalidConfiguration(format!(
299                "Failed to read commands from {}: {e}",
300                path.display()
301            ))
302        })?;
303
304        // Extract registered keybindings
305        let keybindings = Self::extract_keybindings(&lua).map_err(|e| {
306            PluginError::InvalidConfiguration(format!(
307                "Failed to read keybindings from {}: {e}",
308                path.display()
309            ))
310        })?;
311
312        // Extract lifecycle hooks
313        let (on_activate_key, on_deactivate_key) = Self::extract_lifecycle_hooks(&lua);
314
315        // Extract custom theme (if defined)
316        let theme = Self::extract_theme(&lua);
317
318        // Create refresh callbacks map
319        let mut refresh_callbacks = FxHashMap::default();
320
321        // Extract custom table pane types
322        let table_pane_types = Self::extract_table_pane_types(&lua, &name, &mut refresh_callbacks)
323            .map_err(|e| {
324                PluginError::InvalidConfiguration(format!(
325                    "Failed to read table pane types from {}: {e}",
326                    path.display()
327                ))
328            })?;
329
330        // Extract custom chart pane types
331        let chart_pane_types = Self::extract_chart_pane_types(&lua, &name, &mut refresh_callbacks)
332            .map_err(|e| {
333                PluginError::InvalidConfiguration(format!(
334                    "Failed to read chart pane types from {}: {e}",
335                    path.display()
336                ))
337            })?;
338
339        // Extract custom stat pane types
340        let stat_pane_types = Self::extract_stat_pane_types(&lua, &name, &mut refresh_callbacks)
341            .map_err(|e| {
342                PluginError::InvalidConfiguration(format!(
343                    "Failed to read stat pane types from {}: {e}",
344                    path.display()
345                ))
346            })?;
347
348        // Extract custom gauge pane types
349        let gauge_pane_types = Self::extract_gauge_pane_types(&lua, &name, &mut refresh_callbacks)
350            .map_err(|e| {
351                PluginError::InvalidConfiguration(format!(
352                    "Failed to read gauge pane types from {}: {e}",
353                    path.display()
354                ))
355            })?;
356
357        Ok(Self {
358            lua: Mutex::new(lua),
359            name: Box::leak(name.into_boxed_str()),
360            version: Box::leak(version.into_boxed_str()),
361            description: Box::leak(description.into_boxed_str()),
362            commands,
363            keybindings,
364            table_pane_types,
365            chart_pane_types,
366            stat_pane_types,
367            gauge_pane_types,
368            refresh_callbacks,
369            theme,
370            path: path.to_path_buf(),
371            active: false,
372            on_activate_key,
373            on_deactivate_key,
374        })
375    }
376
377    /// Set up the registration API (enya.register_command, enya.keymap).
378    fn setup_registration_api(lua: &Lua) -> LuaResult<()> {
379        let globals = lua.globals();
380
381        // Create the enya table
382        let enya = lua.create_table()?;
383
384        // Storage for registered commands, keybindings, and custom panes (will be extracted later)
385        let registered_commands = lua.create_table()?;
386        let registered_keybindings = lua.create_table()?;
387        let registered_table_panes = lua.create_table()?;
388        let registered_chart_panes = lua.create_table()?;
389        let registered_stat_panes = lua.create_table()?;
390        let registered_gauge_panes = lua.create_table()?;
391
392        // enya.register_command(name, config, callback)
393        let commands_ref = registered_commands.clone();
394        let register_command = lua.create_function(
395            move |lua, (name, config, callback): (String, Table, Function)| {
396                let cmd_table = lua.create_table()?;
397                cmd_table.set("name", name)?;
398                cmd_table.set(
399                    "description",
400                    config
401                        .get::<Option<String>>("description")?
402                        .unwrap_or_default(),
403                )?;
404                cmd_table.set(
405                    "aliases",
406                    config
407                        .get::<Option<Vec<String>>>("aliases")?
408                        .unwrap_or_default(),
409                )?;
410                cmd_table.set(
411                    "accepts_args",
412                    config.get::<Option<bool>>("accepts_args")?.unwrap_or(false),
413                )?;
414                cmd_table.set("callback", callback)?;
415
416                let len = commands_ref.len()? + 1;
417                commands_ref.set(len, cmd_table)?;
418
419                Ok(())
420            },
421        )?;
422        enya.set("register_command", register_command)?;
423
424        // enya.keymap(keys, command, description, modes?)
425        let keybindings_ref = registered_keybindings.clone();
426        let keymap = lua.create_function(move |lua, args: Variadic<Value>| {
427            let args: Vec<Value> = args.into_iter().collect();
428            if args.len() < 2 {
429                return Err(mlua::Error::runtime(
430                    "keymap requires at least 2 arguments: keys, command",
431                ));
432            }
433
434            let keys = match &args[0] {
435                Value::String(s) => s.to_str()?.to_string(),
436                _ => return Err(mlua::Error::runtime("keys must be a string")),
437            };
438
439            let command = match &args[1] {
440                Value::String(s) => s.to_str()?.to_string(),
441                _ => return Err(mlua::Error::runtime("command must be a string")),
442            };
443
444            let description = args
445                .get(2)
446                .and_then(|v| match v {
447                    Value::String(s) => s.to_str().ok().map(|s| s.to_string()),
448                    _ => None,
449                })
450                .unwrap_or_default();
451
452            let modes: Vec<String> = args
453                .get(3)
454                .and_then(|v| match v {
455                    Value::Table(t) => {
456                        let modes: Vec<String> = t
457                            .clone()
458                            .pairs::<i64, String>()
459                            .flatten()
460                            .map(|(_, mode)| mode)
461                            .collect();
462                        Some(modes)
463                    }
464                    _ => None,
465                })
466                .unwrap_or_default();
467
468            let kb_table = lua.create_table()?;
469            kb_table.set("keys", keys)?;
470            kb_table.set("command", command)?;
471            kb_table.set("description", description)?;
472            kb_table.set("modes", modes)?;
473
474            let len = keybindings_ref.len()? + 1;
475            keybindings_ref.set(len, kb_table)?;
476
477            Ok(())
478        })?;
479        enya.set("keymap", keymap)?;
480
481        // enya.register_table_pane(name, config, [callback]) - Register a custom table pane type
482        // The optional callback is called to refresh pane data
483        let table_panes_ref = registered_table_panes.clone();
484        let register_table_pane = lua.create_function(move |lua, args: Variadic<Value>| {
485            let args: Vec<Value> = args.into_iter().collect();
486            if args.len() < 2 {
487                return Err(mlua::Error::runtime(
488                    "register_table_pane requires at least 2 arguments: name, config",
489                ));
490            }
491
492            let name = match &args[0] {
493                Value::String(s) => s.to_str()?.to_string(),
494                _ => return Err(mlua::Error::runtime("name must be a string")),
495            };
496
497            let config = match &args[1] {
498                Value::Table(t) => t.clone(),
499                _ => return Err(mlua::Error::runtime("config must be a table")),
500            };
501
502            let pane_table = lua.create_table()?;
503            pane_table.set("name", name)?;
504            pane_table.set(
505                "title",
506                config
507                    .get::<Option<String>>("title")?
508                    .unwrap_or_else(|| "Custom Table".to_string()),
509            )?;
510            pane_table.set(
511                "refresh_interval",
512                config.get::<Option<u32>>("refresh_interval")?.unwrap_or(0),
513            )?;
514
515            // Extract columns array
516            let columns_table = lua.create_table()?;
517            if let Ok(columns) = config.get::<Table>("columns") {
518                for (idx, col) in columns.pairs::<i64, Table>().flatten() {
519                    let col_table = lua.create_table()?;
520                    col_table.set("name", col.get::<String>("name")?)?;
521                    col_table.set("key", col.get::<Option<String>>("key")?.unwrap_or_default())?;
522                    col_table.set("width", col.get::<Option<f32>>("width")?.unwrap_or(0.0))?;
523                    columns_table.set(idx, col_table)?;
524                }
525            }
526            pane_table.set("columns", columns_table)?;
527
528            // Store callback if provided
529            if let Some(Value::Function(callback)) = args.get(2) {
530                pane_table.set("callback", callback.clone())?;
531            }
532
533            let len = table_panes_ref.len()? + 1;
534            table_panes_ref.set(len, pane_table)?;
535
536            Ok(())
537        })?;
538        enya.set("register_table_pane", register_table_pane)?;
539
540        // enya.register_chart_pane(name, config, [callback]) - Register a custom chart pane type
541        let chart_panes_ref = registered_chart_panes.clone();
542        let register_chart_pane = lua.create_function(move |lua, args: Variadic<Value>| {
543            let args: Vec<Value> = args.into_iter().collect();
544            if args.len() < 2 {
545                return Err(mlua::Error::runtime(
546                    "register_chart_pane requires at least 2 arguments: name, config",
547                ));
548            }
549
550            let name = match &args[0] {
551                Value::String(s) => s.to_str()?.to_string(),
552                _ => return Err(mlua::Error::runtime("name must be a string")),
553            };
554
555            let config = match &args[1] {
556                Value::Table(t) => t.clone(),
557                _ => return Err(mlua::Error::runtime("config must be a table")),
558            };
559
560            let pane_table = lua.create_table()?;
561            pane_table.set("name", name)?;
562            pane_table.set(
563                "title",
564                config
565                    .get::<Option<String>>("title")?
566                    .unwrap_or_else(|| "Custom Chart".to_string()),
567            )?;
568            pane_table.set(
569                "y_unit",
570                config.get::<Option<String>>("y_unit")?.unwrap_or_default(),
571            )?;
572            pane_table.set(
573                "refresh_interval",
574                config.get::<Option<u32>>("refresh_interval")?.unwrap_or(0),
575            )?;
576
577            // Store callback if provided
578            if let Some(Value::Function(callback)) = args.get(2) {
579                pane_table.set("callback", callback.clone())?;
580            }
581
582            let len = chart_panes_ref.len()? + 1;
583            chart_panes_ref.set(len, pane_table)?;
584
585            Ok(())
586        })?;
587        enya.set("register_chart_pane", register_chart_pane)?;
588
589        // enya.register_stat_pane(name, config, [callback]) - Register a custom stat pane type
590        let stat_panes_ref = registered_stat_panes.clone();
591        let register_stat_pane = lua.create_function(move |lua, args: Variadic<Value>| {
592            let args: Vec<Value> = args.into_iter().collect();
593            if args.len() < 2 {
594                return Err(mlua::Error::runtime(
595                    "register_stat_pane requires at least 2 arguments: name, config",
596                ));
597            }
598
599            let name = match &args[0] {
600                Value::String(s) => s.to_str()?.to_string(),
601                _ => return Err(mlua::Error::runtime("name must be a string")),
602            };
603
604            let config = match &args[1] {
605                Value::Table(t) => t.clone(),
606                _ => return Err(mlua::Error::runtime("config must be a table")),
607            };
608
609            let pane_table = lua.create_table()?;
610            pane_table.set("name", name)?;
611            pane_table.set(
612                "title",
613                config
614                    .get::<Option<String>>("title")?
615                    .unwrap_or_else(|| "Custom Stat".to_string()),
616            )?;
617            pane_table.set(
618                "unit",
619                config.get::<Option<String>>("unit")?.unwrap_or_default(),
620            )?;
621            pane_table.set(
622                "refresh_interval",
623                config.get::<Option<u32>>("refresh_interval")?.unwrap_or(0),
624            )?;
625
626            // Store callback if provided
627            if let Some(Value::Function(callback)) = args.get(2) {
628                pane_table.set("callback", callback.clone())?;
629            }
630
631            let len = stat_panes_ref.len()? + 1;
632            stat_panes_ref.set(len, pane_table)?;
633
634            Ok(())
635        })?;
636        enya.set("register_stat_pane", register_stat_pane)?;
637
638        // enya.register_gauge_pane(name, config, [callback]) - Register a custom gauge pane type
639        let gauge_panes_ref = registered_gauge_panes.clone();
640        let register_gauge_pane = lua.create_function(move |lua, args: Variadic<Value>| {
641            let args: Vec<Value> = args.into_iter().collect();
642            if args.len() < 2 {
643                return Err(mlua::Error::runtime(
644                    "register_gauge_pane requires at least 2 arguments: name, config",
645                ));
646            }
647
648            let name = match &args[0] {
649                Value::String(s) => s.to_str()?.to_string(),
650                _ => return Err(mlua::Error::runtime("name must be a string")),
651            };
652
653            let config = match &args[1] {
654                Value::Table(t) => t.clone(),
655                _ => return Err(mlua::Error::runtime("config must be a table")),
656            };
657
658            let pane_table = lua.create_table()?;
659            pane_table.set("name", name)?;
660            pane_table.set(
661                "title",
662                config
663                    .get::<Option<String>>("title")?
664                    .unwrap_or_else(|| "Custom Gauge".to_string()),
665            )?;
666            pane_table.set(
667                "unit",
668                config.get::<Option<String>>("unit")?.unwrap_or_default(),
669            )?;
670            pane_table.set("min", config.get::<Option<f64>>("min")?.unwrap_or(0.0))?;
671            pane_table.set("max", config.get::<Option<f64>>("max")?.unwrap_or(100.0))?;
672            pane_table.set(
673                "refresh_interval",
674                config.get::<Option<u32>>("refresh_interval")?.unwrap_or(0),
675            )?;
676
677            // Store callback if provided
678            if let Some(Value::Function(callback)) = args.get(2) {
679                pane_table.set("callback", callback.clone())?;
680            }
681
682            let len = gauge_panes_ref.len()? + 1;
683            gauge_panes_ref.set(len, pane_table)?;
684
685            Ok(())
686        })?;
687        enya.set("register_gauge_pane", register_gauge_pane)?;
688
689        // Store references for later extraction
690        enya.set("_registered_commands", registered_commands)?;
691        enya.set("_registered_keybindings", registered_keybindings)?;
692        enya.set("_registered_table_panes", registered_table_panes)?;
693        enya.set("_registered_chart_panes", registered_chart_panes)?;
694        enya.set("_registered_stat_panes", registered_stat_panes)?;
695        enya.set("_registered_gauge_panes", registered_gauge_panes)?;
696
697        // Placeholder functions that will be overwritten by setup_runtime_api when we have context.
698        // These no-ops are needed because plugins may call these functions during initial loading.
699        // IMPORTANT: When adding new API functions, add both a no-op here AND the real impl in
700        // setup_runtime_api to keep them in sync.
701        setup_noop_api(lua, &enya)?;
702
703        globals.set("enya", enya)?;
704
705        Ok(())
706    }
707
708    /// Extract plugin metadata from the Lua state.
709    fn extract_metadata(lua: &Lua) -> LuaResult<(String, String, String)> {
710        let globals = lua.globals();
711
712        // Look for a `plugin` table
713        let plugin: Table = globals.get("plugin")?;
714
715        let name: String = plugin.get("name")?;
716        let version: String = plugin
717            .get::<Option<String>>("version")?
718            .unwrap_or_else(|| "0.1.0".to_string());
719        let description: String = plugin
720            .get::<Option<String>>("description")?
721            .unwrap_or_default();
722
723        Ok((name, version, description))
724    }
725
726    /// Extract registered commands from the Lua state.
727    fn extract_commands(lua: &Lua) -> LuaResult<Vec<LuaCommand>> {
728        let globals = lua.globals();
729        let enya: Table = globals.get("enya")?;
730        let registered: Table = enya.get("_registered_commands")?;
731
732        let mut commands = Vec::new();
733
734        for pair in registered.pairs::<i64, Table>() {
735            let (_, cmd_table) = pair?;
736
737            let name: String = cmd_table.get("name")?;
738            let description: String = cmd_table.get("description")?;
739            let aliases: Vec<String> = cmd_table.get("aliases")?;
740            let accepts_args: bool = cmd_table.get("accepts_args")?;
741            let callback: Function = cmd_table.get("callback")?;
742
743            // Store callback in registry for later retrieval
744            let callback_key = lua.create_registry_value(callback)?;
745
746            commands.push(LuaCommand {
747                name,
748                description,
749                aliases,
750                accepts_args,
751                callback_key,
752            });
753        }
754
755        Ok(commands)
756    }
757
758    /// Extract registered keybindings from the Lua state.
759    fn extract_keybindings(lua: &Lua) -> LuaResult<Vec<KeybindingConfig>> {
760        let globals = lua.globals();
761        let enya: Table = globals.get("enya")?;
762        let registered: Table = enya.get("_registered_keybindings")?;
763
764        let mut keybindings = Vec::new();
765
766        for pair in registered.pairs::<i64, Table>() {
767            let (_, kb_table) = pair?;
768
769            keybindings.push(KeybindingConfig {
770                keys: kb_table.get("keys")?,
771                command: kb_table.get("command")?,
772                description: kb_table.get("description")?,
773                modes: kb_table.get("modes")?,
774            });
775        }
776
777        Ok(keybindings)
778    }
779
780    /// Extract registered custom table pane types from the Lua state.
781    /// Also extracts and stores refresh callbacks in the registry.
782    fn extract_table_pane_types(
783        lua: &Lua,
784        plugin_name: &str,
785        refresh_callbacks: &mut FxHashMap<String, RegistryKey>,
786    ) -> LuaResult<Vec<CustomTableConfig>> {
787        let globals = lua.globals();
788        let enya: Table = globals.get("enya")?;
789        let registered: Table = enya.get("_registered_table_panes")?;
790
791        let mut pane_types = Vec::new();
792
793        for pair in registered.pairs::<i64, Table>() {
794            let (_, pane_table) = pair?;
795
796            let name: String = pane_table.get("name")?;
797            let title: String = pane_table.get("title")?;
798            let refresh_interval: u32 = pane_table.get("refresh_interval")?;
799
800            // Extract columns
801            let columns_table: Table = pane_table.get("columns")?;
802            let mut columns = Vec::new();
803
804            for col_pair in columns_table.pairs::<i64, Table>() {
805                let (_, col) = col_pair?;
806                let col_name: String = col.get("name")?;
807                let col_key: String = col.get("key")?;
808                let col_width: f32 = col.get("width")?;
809
810                let mut column = TableColumnConfig::new(col_name);
811                if !col_key.is_empty() {
812                    column = column.with_key(col_key);
813                }
814                if col_width > 0.0 {
815                    // Clamp to valid u32 range to prevent overflow
816                    let width = col_width.min(u32::MAX as f32) as u32;
817                    column = column.with_width(width);
818                }
819                columns.push(column);
820            }
821
822            // Extract and store callback if present
823            if let Ok(callback) = pane_table.get::<Function>("callback") {
824                let callback_key = lua.create_registry_value(callback)?;
825                refresh_callbacks.insert(name.clone(), callback_key);
826            }
827
828            pane_types.push(CustomTableConfig {
829                name,
830                title,
831                columns,
832                refresh_interval,
833                plugin_name: plugin_name.to_string(),
834            });
835        }
836
837        Ok(pane_types)
838    }
839
840    /// Extract registered custom chart pane types from the Lua state.
841    /// Also extracts and stores refresh callbacks in the registry.
842    fn extract_chart_pane_types(
843        lua: &Lua,
844        plugin_name: &str,
845        refresh_callbacks: &mut FxHashMap<String, RegistryKey>,
846    ) -> LuaResult<Vec<CustomChartConfig>> {
847        let globals = lua.globals();
848        let enya: Table = globals.get("enya")?;
849        let registered: Table = enya.get("_registered_chart_panes")?;
850
851        let mut pane_types = Vec::new();
852
853        for pair in registered.pairs::<i64, Table>() {
854            let (_, pane_table) = pair?;
855
856            let name: String = pane_table.get("name")?;
857            let title: String = pane_table.get("title")?;
858            let y_unit: String = pane_table.get("y_unit")?;
859            let refresh_interval: u32 = pane_table.get("refresh_interval")?;
860
861            // Extract and store callback if present
862            if let Ok(callback) = pane_table.get::<Function>("callback") {
863                let callback_key = lua.create_registry_value(callback)?;
864                refresh_callbacks.insert(name.clone(), callback_key);
865            }
866
867            pane_types.push(CustomChartConfig {
868                name,
869                title,
870                y_unit: if y_unit.is_empty() {
871                    None
872                } else {
873                    Some(y_unit)
874                },
875                refresh_interval,
876                plugin_name: plugin_name.to_string(),
877            });
878        }
879
880        Ok(pane_types)
881    }
882
883    /// Extract registered custom stat pane types from the Lua state.
884    /// Also extracts and stores refresh callbacks in the registry.
885    fn extract_stat_pane_types(
886        lua: &Lua,
887        plugin_name: &str,
888        refresh_callbacks: &mut FxHashMap<String, RegistryKey>,
889    ) -> LuaResult<Vec<StatPaneConfig>> {
890        let globals = lua.globals();
891        let enya: Table = globals.get("enya")?;
892        let registered: Table = enya.get("_registered_stat_panes")?;
893
894        let mut pane_types = Vec::new();
895
896        for pair in registered.pairs::<i64, Table>() {
897            let (_, pane_table) = pair?;
898
899            let name: String = pane_table.get("name")?;
900            let title: String = pane_table.get("title")?;
901            let unit: String = pane_table.get("unit")?;
902            let refresh_interval: u32 = pane_table.get("refresh_interval")?;
903
904            // Extract and store callback if present
905            if let Ok(callback) = pane_table.get::<Function>("callback") {
906                let callback_key = lua.create_registry_value(callback)?;
907                refresh_callbacks.insert(name.clone(), callback_key);
908            }
909
910            pane_types.push(StatPaneConfig {
911                name,
912                title,
913                unit: if unit.is_empty() { None } else { Some(unit) },
914                refresh_interval,
915                plugin_name: plugin_name.to_string(),
916            });
917        }
918
919        Ok(pane_types)
920    }
921
922    /// Extract registered custom gauge pane types from the Lua state.
923    /// Also extracts and stores refresh callbacks in the registry.
924    fn extract_gauge_pane_types(
925        lua: &Lua,
926        plugin_name: &str,
927        refresh_callbacks: &mut FxHashMap<String, RegistryKey>,
928    ) -> LuaResult<Vec<GaugePaneConfig>> {
929        let globals = lua.globals();
930        let enya: Table = globals.get("enya")?;
931        let registered: Table = enya.get("_registered_gauge_panes")?;
932
933        let mut pane_types = Vec::new();
934
935        for pair in registered.pairs::<i64, Table>() {
936            let (_, pane_table) = pair?;
937
938            let name: String = pane_table.get("name")?;
939            let title: String = pane_table.get("title")?;
940            let unit: String = pane_table.get("unit")?;
941            let min: f64 = pane_table.get("min")?;
942            let max: f64 = pane_table.get("max")?;
943            let refresh_interval: u32 = pane_table.get("refresh_interval")?;
944
945            // Extract and store callback if present
946            if let Ok(callback) = pane_table.get::<Function>("callback") {
947                let callback_key = lua.create_registry_value(callback)?;
948                refresh_callbacks.insert(name.clone(), callback_key);
949            }
950
951            pane_types.push(GaugePaneConfig {
952                name,
953                title,
954                unit: if unit.is_empty() { None } else { Some(unit) },
955                min_scaled: (min * 1_000_000.0) as i64,
956                max_scaled: (max * 1_000_000.0) as i64,
957                refresh_interval,
958                plugin_name: plugin_name.to_string(),
959            });
960        }
961
962        Ok(pane_types)
963    }
964
965    /// Extract lifecycle hook functions from the Lua state.
966    fn extract_lifecycle_hooks(lua: &Lua) -> (Option<RegistryKey>, Option<RegistryKey>) {
967        let globals = lua.globals();
968
969        let on_activate_key = globals
970            .get::<Function>("on_activate")
971            .ok()
972            .and_then(|f| lua.create_registry_value(f).ok());
973
974        let on_deactivate_key = globals
975            .get::<Function>("on_deactivate")
976            .ok()
977            .and_then(|f| lua.create_registry_value(f).ok());
978
979        (on_activate_key, on_deactivate_key)
980    }
981
982    /// Extract custom theme definition from the Lua state.
983    fn extract_theme(lua: &Lua) -> Option<ThemeDefinition> {
984        let globals = lua.globals();
985
986        // Look for a `theme` table
987        let theme_table: Table = globals.get("theme").ok()?;
988
989        // Required fields
990        let name: String = theme_table.get("name").ok()?;
991
992        // Optional fields with defaults
993        let display_name: String = theme_table
994            .get::<Option<String>>("display_name")
995            .ok()
996            .flatten()
997            .unwrap_or_else(|| name.clone());
998
999        let base_str: String = theme_table
1000            .get::<Option<String>>("base")
1001            .ok()
1002            .flatten()
1003            .unwrap_or_else(|| "dark".to_string());
1004        let base = ThemeBase::parse(&base_str);
1005
1006        // Parse colors table
1007        let colors = Self::extract_theme_colors(lua, &theme_table);
1008
1009        Some(ThemeDefinition {
1010            name,
1011            display_name,
1012            base,
1013            colors,
1014        })
1015    }
1016
1017    /// Extract color palette from theme table.
1018    fn extract_theme_colors(_lua: &Lua, theme_table: &Table) -> ThemeColors {
1019        let colors_table: Option<Table> = theme_table.get("colors").ok();
1020        let mut colors = ThemeColors::default();
1021
1022        let Some(ct) = colors_table else {
1023            return colors;
1024        };
1025
1026        // Helper to parse a color field
1027        let parse_color = |key: &str| -> Option<u32> {
1028            ct.get::<Option<String>>(key)
1029                .ok()
1030                .flatten()
1031                .and_then(|s| ThemeColors::parse_hex(&s))
1032        };
1033
1034        // Backgrounds
1035        colors.bg_base = parse_color("bg_base");
1036        colors.bg_surface = parse_color("bg_surface");
1037        colors.bg_elevated = parse_color("bg_elevated");
1038
1039        // Text
1040        colors.text_primary = parse_color("text_primary");
1041        colors.text_secondary = parse_color("text_secondary");
1042        colors.text_muted = parse_color("text_muted");
1043
1044        // Accents
1045        colors.accent_primary = parse_color("accent_primary");
1046        colors.accent_hover = parse_color("accent_hover");
1047        colors.accent_muted = parse_color("accent_muted");
1048
1049        // Borders
1050        colors.border_subtle = parse_color("border_subtle");
1051        colors.border_strong = parse_color("border_strong");
1052
1053        // Semantic colors
1054        colors.success = parse_color("success");
1055        colors.warning = parse_color("warning");
1056        colors.error = parse_color("error");
1057        colors.info = parse_color("info");
1058
1059        // Chart palette (array of hex colors)
1060        if let Ok(Some(chart_table)) = ct.get::<Option<Table>>("chart") {
1061            let mut palette = Vec::new();
1062            for (_, hex) in chart_table.pairs::<i64, String>().flatten() {
1063                if let Some(color) = ThemeColors::parse_hex(&hex) {
1064                    palette.push(color);
1065                }
1066            }
1067            colors.chart_palette = palette;
1068        }
1069
1070        colors
1071    }
1072
1073    /// Set up the runtime API with access to PluginContext.
1074    fn setup_runtime_api<'lua, 'scope>(
1075        lua: &'lua Lua,
1076        scope: &'lua mlua::Scope<'lua, 'scope>,
1077        ctx: &'scope PluginContext,
1078    ) -> LuaResult<()>
1079    where
1080        'scope: 'lua,
1081    {
1082        let globals = lua.globals();
1083        let enya: Table = globals.get("enya")?;
1084
1085        // enya.notify(level, message)
1086        let notify_fn = scope.create_function(|_, (level, msg): (String, String)| {
1087            ctx.notify(&level, &msg);
1088            Ok(())
1089        })?;
1090        enya.set("notify", notify_fn)?;
1091
1092        // enya.log(level, message)
1093        let log_fn = scope.create_function(|_, (level, msg): (String, String)| {
1094            let log_level = LogLevel::parse(&level);
1095            ctx.log(log_level, &msg);
1096            Ok(())
1097        })?;
1098        enya.set("log", log_fn)?;
1099
1100        // enya.request_repaint()
1101        let repaint_fn = scope.create_function(|_, ()| {
1102            ctx.request_repaint();
1103            Ok(())
1104        })?;
1105        enya.set("request_repaint", repaint_fn)?;
1106
1107        // enya.editor_version()
1108        let version = ctx.editor_version();
1109        let version_fn = scope.create_function(move |_, ()| Ok(version.to_string()))?;
1110        enya.set("editor_version", version_fn)?;
1111
1112        // enya.is_wasm()
1113        let is_wasm = ctx.is_wasm();
1114        let wasm_fn = scope.create_function(move |_, ()| Ok(is_wasm))?;
1115        enya.set("is_wasm", wasm_fn)?;
1116
1117        // enya.theme_name()
1118        let theme_name = ctx.theme_name();
1119        let theme_fn = scope.create_function(move |_, ()| Ok(theme_name.to_string()))?;
1120        enya.set("theme_name", theme_fn)?;
1121
1122        // enya.clipboard_write(text)
1123        let clipboard_write_fn =
1124            scope.create_function(|_, text: String| Ok(ctx.clipboard_write(&text)))?;
1125        enya.set("clipboard_write", clipboard_write_fn)?;
1126
1127        // enya.clipboard_read()
1128        let clipboard_read_fn = scope.create_function(|_, ()| Ok(ctx.clipboard_read()))?;
1129        enya.set("clipboard_read", clipboard_read_fn)?;
1130
1131        // enya.execute(command, args?) - Execute another command
1132        // Note: This is a simplified version that just logs the intent
1133        // A full implementation would need access to the command dispatcher
1134        let execute_fn = scope.create_function(|_, (cmd, args): (String, Option<String>)| {
1135            let args_str = args.as_deref().unwrap_or("");
1136            log::info!("[lua] Execute request: {cmd} {args_str}");
1137            // For now, we can't actually execute commands from within Lua
1138            // This would require deeper integration with the command system
1139            Ok(true)
1140        })?;
1141        enya.set("execute", execute_fn)?;
1142
1143        // enya.http_get(url, headers?) - Perform HTTP GET request
1144        // Returns { status = 200, body = "...", headers = {...} } or { error = "..." }
1145        let http_get_fn =
1146            scope.create_function(|lua, (url, headers): (String, Option<Table>)| {
1147                use rustc_hash::FxHashMap;
1148
1149                let mut header_map = FxHashMap::default();
1150                if let Some(h) = headers {
1151                    for pair in h.pairs::<String, String>() {
1152                        let (k, v) = pair?;
1153                        header_map.insert(k, v);
1154                    }
1155                }
1156
1157                let result = ctx.http_get(&url, &header_map);
1158                let response_table = lua.create_table()?;
1159
1160                match result {
1161                    Ok(resp) => {
1162                        response_table.set("status", resp.status)?;
1163                        response_table.set("body", resp.body)?;
1164                        let headers_table = lua.create_table()?;
1165                        for (k, v) in resp.headers {
1166                            headers_table.set(k, v)?;
1167                        }
1168                        response_table.set("headers", headers_table)?;
1169                    }
1170                    Err(e) => {
1171                        response_table.set("error", e.message)?;
1172                    }
1173                }
1174
1175                Ok(response_table)
1176            })?;
1177        enya.set("http_get", http_get_fn)?;
1178
1179        // enya.http_post(url, body, headers?) - Perform HTTP POST request
1180        // Returns { status = 200, body = "...", headers = {...} } or { error = "..." }
1181        let http_post_fn = scope.create_function(
1182            |lua, (url, body, headers): (String, String, Option<Table>)| {
1183                use rustc_hash::FxHashMap;
1184
1185                let mut header_map = FxHashMap::default();
1186                if let Some(h) = headers {
1187                    for pair in h.pairs::<String, String>() {
1188                        let (k, v) = pair?;
1189                        header_map.insert(k, v);
1190                    }
1191                }
1192
1193                let result = ctx.http_post(&url, &body, &header_map);
1194                let response_table = lua.create_table()?;
1195
1196                match result {
1197                    Ok(resp) => {
1198                        response_table.set("status", resp.status)?;
1199                        response_table.set("body", resp.body)?;
1200                        let headers_table = lua.create_table()?;
1201                        for (k, v) in resp.headers {
1202                            headers_table.set(k, v)?;
1203                        }
1204                        response_table.set("headers", headers_table)?;
1205                    }
1206                    Err(e) => {
1207                        response_table.set("error", e.message)?;
1208                    }
1209                }
1210
1211                Ok(response_table)
1212            },
1213        )?;
1214        enya.set("http_post", http_post_fn)?;
1215
1216        // ==================== Pane Management API ====================
1217
1218        // enya.add_query_pane(query, [title]) - Add a query pane with PromQL query
1219        let add_query_pane_fn =
1220            scope.create_function(|_, (query, title): (String, Option<String>)| {
1221                ctx.add_query_pane(&query, title.as_deref());
1222                Ok(())
1223            })?;
1224        enya.set("add_query_pane", add_query_pane_fn)?;
1225
1226        // enya.add_logs_pane() - Add a logs pane
1227        let add_logs_pane_fn = scope.create_function(|_, ()| {
1228            ctx.add_logs_pane();
1229            Ok(())
1230        })?;
1231        enya.set("add_logs_pane", add_logs_pane_fn)?;
1232
1233        // enya.add_tracing_pane([trace_id]) - Add a tracing pane, optionally with a trace ID
1234        let add_tracing_pane_fn = scope.create_function(|_, trace_id: Option<String>| {
1235            ctx.add_tracing_pane(trace_id.as_deref());
1236            Ok(())
1237        })?;
1238        enya.set("add_tracing_pane", add_tracing_pane_fn)?;
1239
1240        // enya.add_terminal_pane() - Add a terminal pane (native only)
1241        let add_terminal_pane_fn = scope.create_function(|_, ()| {
1242            ctx.add_terminal_pane();
1243            Ok(())
1244        })?;
1245        enya.set("add_terminal_pane", add_terminal_pane_fn)?;
1246
1247        // enya.add_sql_pane() - Add a SQL pane
1248        let add_sql_pane_fn = scope.create_function(|_, ()| {
1249            ctx.add_sql_pane();
1250            Ok(())
1251        })?;
1252        enya.set("add_sql_pane", add_sql_pane_fn)?;
1253
1254        // enya.close_pane() - Close the focused pane
1255        let close_pane_fn = scope.create_function(|_, ()| {
1256            ctx.close_focused_pane();
1257            Ok(())
1258        })?;
1259        enya.set("close_pane", close_pane_fn)?;
1260
1261        // enya.focus_pane(direction) - Focus pane in direction ("left", "right", "up", "down")
1262        let focus_pane_fn = scope.create_function(|_, direction: String| {
1263            ctx.focus_pane(&direction);
1264            Ok(())
1265        })?;
1266        enya.set("focus_pane", focus_pane_fn)?;
1267
1268        // ==================== Time Range API ====================
1269
1270        // enya.set_time_range(preset) - Set time range to preset ("5m", "1h", "24h", etc.)
1271        let set_time_range_fn = scope.create_function(|_, preset: String| {
1272            ctx.set_time_range_preset(&preset);
1273            Ok(())
1274        })?;
1275        enya.set("set_time_range", set_time_range_fn)?;
1276
1277        // enya.set_time_range_absolute(start_secs, end_secs) - Set absolute time range
1278        let set_time_range_absolute_fn = scope.create_function(|_, (start, end): (f64, f64)| {
1279            ctx.set_time_range_absolute(start, end);
1280            Ok(())
1281        })?;
1282        enya.set("set_time_range_absolute", set_time_range_absolute_fn)?;
1283
1284        // enya.get_time_range() - Get current time range as {start, end}
1285        let get_time_range_fn = scope.create_function(|lua, ()| {
1286            let (start, end) = ctx.get_time_range();
1287            let table = lua.create_table()?;
1288            table.set("start", start)?;
1289            table.set("end", end)?;
1290            Ok(table)
1291        })?;
1292        enya.set("get_time_range", get_time_range_fn)?;
1293
1294        // enya.get_focused_pane() - Get info about the currently focused pane
1295        // Returns { pane_type, title, query, metric_name } or nil if no pane is focused
1296        let get_focused_pane_fn = scope.create_function(|lua, ()| {
1297            if let Some(info) = ctx.get_focused_pane_info() {
1298                let table = lua.create_table()?;
1299                table.set("pane_type", info.pane_type)?;
1300                if let Some(title) = info.title {
1301                    table.set("title", title)?;
1302                }
1303                if let Some(query) = info.query {
1304                    table.set("query", query)?;
1305                }
1306                if let Some(metric_name) = info.metric_name {
1307                    table.set("metric_name", metric_name)?;
1308                }
1309                Ok(mlua::Value::Table(table))
1310            } else {
1311                Ok(mlua::Value::Nil)
1312            }
1313        })?;
1314        enya.set("get_focused_pane", get_focused_pane_fn)?;
1315
1316        // ==================== Custom Pane API ====================
1317
1318        // enya.add_custom_pane(pane_type) - Add an instance of a custom table pane
1319        let add_custom_pane_fn = scope.create_function(|_, pane_type: String| {
1320            ctx.add_custom_table_pane(&pane_type);
1321            Ok(())
1322        })?;
1323        enya.set("add_custom_pane", add_custom_pane_fn)?;
1324
1325        // enya.set_pane_data(pane_type, data) - Update data for all instances of a custom table pane type
1326        // data = { rows = { {col1 = "value1", col2 = "value2"}, {...} } } or { error = "message" }
1327        let set_pane_data_fn = scope.create_function(|_, (pane_type, data): (String, Table)| {
1328            use crate::{CustomTableData, CustomTableRow};
1329
1330            // Check for error field
1331            if let Ok(Some(error)) = data.get::<Option<String>>("error") {
1332                let table_data = CustomTableData::with_error(error);
1333                ctx.update_custom_table_data_by_type(&pane_type, table_data);
1334                return Ok(());
1335            }
1336
1337            // Extract rows
1338            let mut rows = Vec::new();
1339            if let Ok(rows_table) = data.get::<Table>("rows") {
1340                for (_, row_table) in rows_table.pairs::<i64, Table>().flatten() {
1341                    let mut row = CustomTableRow::new();
1342                    for (key, value) in row_table.pairs::<String, String>().flatten() {
1343                        row = row.with_cell(key, value);
1344                    }
1345                    rows.push(row);
1346                }
1347            }
1348
1349            let table_data = CustomTableData::with_rows(rows);
1350            ctx.update_custom_table_data_by_type(&pane_type, table_data);
1351            Ok(())
1352        })?;
1353        enya.set("set_pane_data", set_pane_data_fn)?;
1354
1355        // ==================== Custom Chart Pane API ====================
1356
1357        // enya.add_chart_pane(pane_type) - Add an instance of a custom chart pane
1358        let add_chart_pane_fn = scope.create_function(|_, pane_type: String| {
1359            ctx.add_custom_chart_pane(&pane_type);
1360            Ok(())
1361        })?;
1362        enya.set("add_chart_pane", add_chart_pane_fn)?;
1363
1364        // enya.set_chart_data(pane_type, data) - Update data for all instances of a custom chart pane type
1365        // data = {
1366        //   series = {
1367        //     { name = "Series1", tags = { key = "value" }, points = { { timestamp = 1234567890, value = 123.4 }, ... } },
1368        //     ...
1369        //   }
1370        // } or { error = "message" }
1371        let set_chart_data_fn =
1372            scope.create_function(|_, (pane_type, data): (String, Table)| {
1373                use crate::{ChartDataPoint, ChartSeries, CustomChartData};
1374
1375                // Check for error field
1376                if let Ok(Some(error)) = data.get::<Option<String>>("error") {
1377                    let chart_data = CustomChartData::with_error(error);
1378                    ctx.update_custom_chart_data_by_type(&pane_type, chart_data);
1379                    return Ok(());
1380                }
1381
1382                // Extract series
1383                let mut series_vec = Vec::new();
1384                if let Ok(series_table) = data.get::<Table>("series") {
1385                    for (_, series_entry) in series_table.pairs::<i64, Table>().flatten() {
1386                        let name: String = series_entry
1387                            .get::<Option<String>>("name")?
1388                            .unwrap_or_else(|| "unnamed".to_string());
1389
1390                        let mut series = ChartSeries::new(name);
1391
1392                        // Extract tags
1393                        if let Ok(tags_table) = series_entry.get::<Table>("tags") {
1394                            for (key, value) in tags_table.pairs::<String, String>().flatten() {
1395                                series = series.with_tag(key, value);
1396                            }
1397                        }
1398
1399                        // Extract points
1400                        if let Ok(points_table) = series_entry.get::<Table>("points") {
1401                            for (_, point_entry) in points_table.pairs::<i64, Table>().flatten() {
1402                                let timestamp: f64 = point_entry.get("timestamp")?;
1403                                let value: f64 = point_entry.get("value")?;
1404                                series.points.push(ChartDataPoint::new(timestamp, value));
1405                            }
1406                        }
1407
1408                        series_vec.push(series);
1409                    }
1410                }
1411
1412                let chart_data = CustomChartData::with_series(series_vec);
1413                ctx.update_custom_chart_data_by_type(&pane_type, chart_data);
1414                Ok(())
1415            })?;
1416        enya.set("set_chart_data", set_chart_data_fn)?;
1417
1418        // ==================== Custom Stat Pane API ====================
1419
1420        // enya.add_stat_pane(pane_type) - Add an instance of a custom stat pane
1421        let add_stat_pane_fn = scope.create_function(|_, pane_type: String| {
1422            ctx.add_stat_pane(&pane_type);
1423            Ok(())
1424        })?;
1425        enya.set("add_stat_pane", add_stat_pane_fn)?;
1426
1427        // enya.set_stat_data(pane_type, data) - Update data for all instances of a custom stat pane type
1428        // data = {
1429        //   value = 123.4,                        -- The current value
1430        //   sparkline = { 1, 2, 3, 4, 5 },       -- Optional sparkline history
1431        //   change_value = 5.2,                   -- Optional % change
1432        //   change_period = "vs last hour",       -- Optional change period description
1433        //   thresholds = {                        -- Optional thresholds
1434        //     { value = 50, color = "yellow" },
1435        //     { value = 80, color = "red" }
1436        //   }
1437        // } or { error = "message" }
1438        let set_stat_data_fn = scope.create_function(|_, (pane_type, data): (String, Table)| {
1439            use crate::{StatPaneData, ThresholdConfig};
1440
1441            // Check for error field
1442            if let Ok(Some(error)) = data.get::<Option<String>>("error") {
1443                let stat_data = StatPaneData::with_error(error);
1444                ctx.update_stat_data_by_type(&pane_type, stat_data);
1445                return Ok(());
1446            }
1447
1448            let value: f64 = data.get::<Option<f64>>("value")?.unwrap_or(0.0);
1449            let mut stat_data = StatPaneData::with_value(value);
1450
1451            // Extract sparkline
1452            if let Ok(sparkline_table) = data.get::<Table>("sparkline") {
1453                let mut sparkline = Vec::new();
1454                for (_, val) in sparkline_table.pairs::<i64, f64>().flatten() {
1455                    sparkline.push(val);
1456                }
1457                stat_data.sparkline = sparkline;
1458            }
1459
1460            // Extract change
1461            if let Ok(Some(change_value)) = data.get::<Option<f64>>("change_value") {
1462                let change_period: String = data
1463                    .get::<Option<String>>("change_period")?
1464                    .unwrap_or_else(|| "vs last period".to_string());
1465                stat_data.change_value = Some(change_value);
1466                stat_data.change_period = Some(change_period);
1467            }
1468
1469            // Extract thresholds
1470            if let Ok(thresholds_table) = data.get::<Table>("thresholds") {
1471                for (_, thresh) in thresholds_table.pairs::<i64, Table>().flatten() {
1472                    let value: f64 = thresh.get("value")?;
1473                    let color: String = thresh.get("color")?;
1474                    let label: Option<String> = thresh.get::<Option<String>>("label")?;
1475                    let mut threshold = ThresholdConfig::new(value, color);
1476                    if let Some(lbl) = label {
1477                        threshold = threshold.with_label(lbl);
1478                    }
1479                    stat_data.thresholds.push(threshold);
1480                }
1481            }
1482
1483            ctx.update_stat_data_by_type(&pane_type, stat_data);
1484            Ok(())
1485        })?;
1486        enya.set("set_stat_data", set_stat_data_fn)?;
1487
1488        // ==================== Custom Gauge Pane API ====================
1489
1490        // enya.add_gauge_pane(pane_type) - Add an instance of a custom gauge pane
1491        let add_gauge_pane_fn = scope.create_function(|_, pane_type: String| {
1492            ctx.add_gauge_pane(&pane_type);
1493            Ok(())
1494        })?;
1495        enya.set("add_gauge_pane", add_gauge_pane_fn)?;
1496
1497        // enya.set_gauge_data(pane_type, data) - Update data for all instances of a custom gauge pane type
1498        // data = {
1499        //   value = 75.5,                         -- The current value
1500        //   thresholds = {                        -- Optional thresholds
1501        //     { value = 50, color = "yellow" },
1502        //     { value = 80, color = "red" }
1503        //   }
1504        // } or { error = "message" }
1505        let set_gauge_data_fn =
1506            scope.create_function(|_, (pane_type, data): (String, Table)| {
1507                use crate::{GaugePaneData, ThresholdConfig};
1508
1509                // Check for error field
1510                if let Ok(Some(error)) = data.get::<Option<String>>("error") {
1511                    let gauge_data = GaugePaneData::with_error(error);
1512                    ctx.update_gauge_data_by_type(&pane_type, gauge_data);
1513                    return Ok(());
1514                }
1515
1516                let value: f64 = data.get::<Option<f64>>("value")?.unwrap_or(0.0);
1517                let mut gauge_data = GaugePaneData::with_value(value);
1518
1519                // Extract thresholds
1520                if let Ok(thresholds_table) = data.get::<Table>("thresholds") {
1521                    for (_, thresh) in thresholds_table.pairs::<i64, Table>().flatten() {
1522                        let value: f64 = thresh.get("value")?;
1523                        let color: String = thresh.get("color")?;
1524                        let label: Option<String> = thresh.get::<Option<String>>("label")?;
1525                        let mut threshold = ThresholdConfig::new(value, color);
1526                        if let Some(lbl) = label {
1527                            threshold = threshold.with_label(lbl);
1528                        }
1529                        gauge_data.thresholds.push(threshold);
1530                    }
1531                }
1532
1533                ctx.update_gauge_data_by_type(&pane_type, gauge_data);
1534                Ok(())
1535            })?;
1536        enya.set("set_gauge_data", set_gauge_data_fn)?;
1537
1538        Ok(())
1539    }
1540
1541    /// Call a lifecycle hook if it exists.
1542    fn call_lifecycle_hook(
1543        &self,
1544        hook_key: &Option<RegistryKey>,
1545        ctx: &PluginContext,
1546    ) -> PluginResult<()> {
1547        let Some(key) = hook_key else {
1548            return Ok(());
1549        };
1550
1551        let lua = self.lua.lock();
1552
1553        lua.scope(|scope| {
1554            Self::setup_runtime_api(&lua, scope, ctx)?;
1555
1556            let hook: Function = lua.registry_value(key)?;
1557            hook.call::<()>(())?;
1558
1559            Ok(())
1560        })
1561        .map_err(|e| PluginError::OperationFailed(format!("Lifecycle hook failed: {e}")))
1562    }
1563}
1564
1565impl Plugin for LuaPlugin {
1566    fn name(&self) -> &'static str {
1567        self.name
1568    }
1569
1570    fn version(&self) -> &'static str {
1571        self.version
1572    }
1573
1574    fn description(&self) -> &'static str {
1575        self.description
1576    }
1577
1578    fn capabilities(&self) -> PluginCapabilities {
1579        let mut caps = PluginCapabilities::empty();
1580        if !self.commands.is_empty() {
1581            caps |= PluginCapabilities::COMMANDS;
1582        }
1583        if !self.keybindings.is_empty() {
1584            caps |= PluginCapabilities::KEYBOARD;
1585        }
1586        if self.theme.is_some() {
1587            caps |= PluginCapabilities::CUSTOM_THEMES;
1588        }
1589        if !self.table_pane_types.is_empty()
1590            || !self.chart_pane_types.is_empty()
1591            || !self.stat_pane_types.is_empty()
1592            || !self.gauge_pane_types.is_empty()
1593        {
1594            caps |= PluginCapabilities::PANES;
1595        }
1596        caps
1597    }
1598
1599    fn init(&mut self, _ctx: &PluginContext) -> PluginResult<()> {
1600        log::info!(
1601            "[plugin:{}] Lua plugin loaded from {}",
1602            self.name,
1603            self.path.display()
1604        );
1605        Ok(())
1606    }
1607
1608    fn activate(&mut self, ctx: &PluginContext) -> PluginResult<()> {
1609        self.active = true;
1610
1611        // Call on_activate hook if defined
1612        if self.on_activate_key.is_some() {
1613            self.call_lifecycle_hook(&self.on_activate_key, ctx)?;
1614        }
1615
1616        log::info!("[plugin:{}] Activated", self.name);
1617        Ok(())
1618    }
1619
1620    fn deactivate(&mut self, ctx: &PluginContext) -> PluginResult<()> {
1621        // Call on_deactivate hook if defined
1622        if self.on_deactivate_key.is_some() {
1623            self.call_lifecycle_hook(&self.on_deactivate_key, ctx)?;
1624        }
1625
1626        self.active = false;
1627        log::info!("[plugin:{}] Deactivated", self.name);
1628        Ok(())
1629    }
1630
1631    fn commands(&self) -> Vec<CommandConfig> {
1632        self.commands
1633            .iter()
1634            .map(|c| CommandConfig {
1635                name: c.name.clone(),
1636                aliases: c.aliases.clone(),
1637                description: c.description.clone(),
1638                accepts_args: c.accepts_args,
1639            })
1640            .collect()
1641    }
1642
1643    fn keybindings(&self) -> Vec<KeybindingConfig> {
1644        self.keybindings.clone()
1645    }
1646
1647    fn themes(&self) -> Vec<ThemeDefinition> {
1648        self.theme.clone().into_iter().collect()
1649    }
1650
1651    fn custom_table_panes(&self) -> Vec<crate::CustomTableConfig> {
1652        self.table_pane_types.clone()
1653    }
1654
1655    fn custom_chart_panes(&self) -> Vec<crate::CustomChartConfig> {
1656        self.chart_pane_types.clone()
1657    }
1658
1659    fn custom_stat_panes(&self) -> Vec<crate::StatPaneConfig> {
1660        self.stat_pane_types.clone()
1661    }
1662
1663    fn custom_gauge_panes(&self) -> Vec<crate::GaugePaneConfig> {
1664        self.gauge_pane_types.clone()
1665    }
1666
1667    fn refreshable_pane_types(&self) -> Vec<(&str, u32)> {
1668        let mut result = Vec::new();
1669
1670        // Only include pane types that have both a refresh callback and a non-zero interval
1671        for config in &self.table_pane_types {
1672            if config.refresh_interval > 0 && self.refresh_callbacks.contains_key(&config.name) {
1673                result.push((config.name.as_str(), config.refresh_interval));
1674            }
1675        }
1676        for config in &self.chart_pane_types {
1677            if config.refresh_interval > 0 && self.refresh_callbacks.contains_key(&config.name) {
1678                result.push((config.name.as_str(), config.refresh_interval));
1679            }
1680        }
1681        for config in &self.stat_pane_types {
1682            if config.refresh_interval > 0 && self.refresh_callbacks.contains_key(&config.name) {
1683                result.push((config.name.as_str(), config.refresh_interval));
1684            }
1685        }
1686        for config in &self.gauge_pane_types {
1687            if config.refresh_interval > 0 && self.refresh_callbacks.contains_key(&config.name) {
1688                result.push((config.name.as_str(), config.refresh_interval));
1689            }
1690        }
1691
1692        result
1693    }
1694
1695    fn trigger_pane_refresh(&mut self, pane_type: &str, ctx: &PluginContext) -> bool {
1696        let Some(callback_key) = self.refresh_callbacks.get(pane_type) else {
1697            log::warn!(
1698                "[plugin:{}] No refresh callback for pane type '{}'",
1699                self.name,
1700                pane_type
1701            );
1702            return false;
1703        };
1704
1705        let lua = self.lua.lock();
1706
1707        let result = lua.scope(|scope| {
1708            // Set up runtime API with current context
1709            Self::setup_runtime_api(&lua, scope, ctx)?;
1710
1711            // Get the callback from registry
1712            let callback: Function = lua.registry_value(callback_key)?;
1713
1714            // Call the callback (no arguments)
1715            callback.call::<()>(())?;
1716
1717            Ok(())
1718        });
1719
1720        match result {
1721            Ok(()) => {
1722                log::debug!("[plugin:{}] Refreshed pane type '{}'", self.name, pane_type);
1723                true
1724            }
1725            Err(e) => {
1726                log::error!(
1727                    "[plugin:{}] Error refreshing pane type '{}': {e}",
1728                    self.name,
1729                    pane_type
1730                );
1731                ctx.notify("error", &format!("Plugin refresh error: {e}"));
1732                false
1733            }
1734        }
1735    }
1736
1737    fn execute_command(&mut self, command: &str, args: &str, ctx: &PluginContext) -> bool {
1738        // Find the command
1739        let cmd = self
1740            .commands
1741            .iter()
1742            .find(|c| c.name == command || c.aliases.contains(&command.to_string()));
1743
1744        let Some(cmd) = cmd else {
1745            return false;
1746        };
1747
1748        let lua = self.lua.lock();
1749
1750        // Use scope to create functions with non-'static lifetime
1751        let result = lua.scope(|scope| {
1752            // Set up runtime API with current context
1753            Self::setup_runtime_api(&lua, scope, ctx)?;
1754
1755            // Get the callback from registry
1756            let callback: Function = lua.registry_value(&cmd.callback_key)?;
1757
1758            // Call the callback with args
1759            let success: bool = callback.call(args)?;
1760
1761            Ok(success)
1762        });
1763
1764        match result {
1765            Ok(success) => success,
1766            Err(e) => {
1767                log::error!(
1768                    "[plugin:{}] Error executing command '{}': {e}",
1769                    self.name,
1770                    command
1771                );
1772                ctx.notify("error", &format!("Plugin error: {e}"));
1773                false
1774            }
1775        }
1776    }
1777
1778    fn as_any(&self) -> &dyn Any {
1779        self
1780    }
1781
1782    fn as_any_mut(&mut self) -> &mut dyn Any {
1783        self
1784    }
1785}
1786
1787/// Example Lua plugin template.
1788pub const EXAMPLE_LUA_PLUGIN: &str = r#"-- Example Enya Lua Plugin
1789-- Place this file in ~/.enya/plugins/
1790
1791-- Plugin metadata (required)
1792plugin = {
1793    name = "example-lua",
1794    version = "0.1.0",
1795    description = "An example Lua plugin showing available features"
1796}
1797
1798-- Register a simple command
1799enya.register_command("lua-hello", {
1800    description = "Say hello from Lua",
1801    aliases = {"lhello"},
1802    accepts_args = true
1803}, function(args)
1804    if args == "" then
1805        enya.notify("info", "Hello from Lua!")
1806    else
1807        enya.notify("info", "Hello, " .. args .. "!")
1808    end
1809    return true
1810end)
1811
1812-- Register a command with conditional logic
1813enya.register_command("lua-check", {
1814    description = "Check something with conditional logic",
1815    accepts_args = true
1816}, function(args)
1817    local num = tonumber(args)
1818    if num == nil then
1819        enya.notify("error", "Please provide a number")
1820        return false
1821    end
1822
1823    if num > 100 then
1824        enya.notify("warn", "That's a large number: " .. tostring(num))
1825    elseif num < 0 then
1826        enya.notify("error", "Negative numbers not allowed")
1827        return false
1828    else
1829        enya.notify("info", "Number " .. tostring(num) .. " is valid")
1830    end
1831
1832    return true
1833end)
1834
1835-- Register keybindings
1836enya.keymap("Space+l+h", "lua-hello", "Lua hello")
1837enya.keymap("Space+l+c", "lua-check", "Lua check")
1838
1839-- Lifecycle hooks (optional)
1840function on_activate()
1841    enya.log("info", "Example Lua plugin activated!")
1842end
1843
1844function on_deactivate()
1845    enya.log("info", "Example Lua plugin deactivated!")
1846end
1847"#;
1848
1849#[cfg(test)]
1850mod tests {
1851    use super::*;
1852    use std::path::PathBuf;
1853
1854    #[test]
1855    fn test_load_simple_plugin() {
1856        let source = r#"
1857            plugin = {
1858                name = "test-plugin",
1859                version = "1.0.0",
1860                description = "A test plugin"
1861            }
1862
1863            enya.register_command("test-cmd", {
1864                description = "Test command",
1865                aliases = {"tc"},
1866                accepts_args = false
1867            }, function(args)
1868                return true
1869            end)
1870        "#;
1871
1872        let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
1873
1874        assert_eq!(plugin.name(), "test-plugin");
1875        assert_eq!(plugin.version(), "1.0.0");
1876        assert_eq!(plugin.description(), "A test plugin");
1877        assert_eq!(plugin.commands().len(), 1);
1878        assert_eq!(plugin.commands()[0].name, "test-cmd");
1879    }
1880
1881    #[test]
1882    fn test_keybindings() {
1883        let source = r#"
1884            plugin = { name = "kb-test" }
1885
1886            enya.register_command("my-cmd", {}, function() return true end)
1887            enya.keymap("Space+t+t", "my-cmd", "Test binding", {"normal"})
1888        "#;
1889
1890        let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
1891
1892        assert_eq!(plugin.keybindings().len(), 1);
1893        assert_eq!(plugin.keybindings()[0].keys, "Space+t+t");
1894        assert_eq!(plugin.keybindings()[0].command, "my-cmd");
1895        assert_eq!(plugin.keybindings()[0].modes, vec!["normal"]);
1896    }
1897
1898    #[test]
1899    fn test_missing_metadata() {
1900        let source = r#"
1901            -- No plugin table defined
1902            enya.register_command("test", {}, function() return true end)
1903        "#;
1904
1905        let result = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua"));
1906        assert!(result.is_err());
1907    }
1908
1909    #[test]
1910    fn test_syntax_error() {
1911        let source = r#"
1912            plugin = { name = "broken"
1913            -- Missing closing brace
1914        "#;
1915
1916        let result = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua"));
1917        assert!(result.is_err());
1918    }
1919
1920    #[test]
1921    fn test_theme_plugin() {
1922        let source = r##"
1923            plugin = {
1924                name = "tokyo-night-theme",
1925                version = "1.0.0",
1926                description = "Tokyo Night color theme"
1927            }
1928
1929            theme = {
1930                name = "tokyo-night",
1931                display_name = "Tokyo Night",
1932                base = "dark",
1933                colors = {
1934                    bg_base = "#1a1b26",
1935                    bg_surface = "#24283b",
1936                    accent_primary = "#7aa2f7",
1937                    accent_hover = "#89b4fa",
1938                    success = "#9ece6a",
1939                    error = "#f7768e",
1940                    chart = {
1941                        "#7aa2f7",
1942                        "#9ece6a",
1943                        "#e0af68",
1944                        "#f7768e",
1945                    }
1946                }
1947            }
1948        "##;
1949
1950        let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
1951
1952        assert_eq!(plugin.name(), "tokyo-night-theme");
1953
1954        // Check capabilities include CUSTOM_THEMES
1955        assert!(
1956            plugin
1957                .capabilities()
1958                .contains(PluginCapabilities::CUSTOM_THEMES)
1959        );
1960
1961        // Check theme was parsed
1962        let themes = plugin.themes();
1963        assert_eq!(themes.len(), 1);
1964
1965        let theme = &themes[0];
1966        assert_eq!(theme.name, "tokyo-night");
1967        assert_eq!(theme.display_name, "Tokyo Night");
1968        assert_eq!(theme.base, ThemeBase::Dark);
1969
1970        // Check colors
1971        assert_eq!(theme.colors.bg_base, Some(0x1a1b26));
1972        assert_eq!(theme.colors.bg_surface, Some(0x24283b));
1973        assert_eq!(theme.colors.accent_primary, Some(0x7aa2f7));
1974        assert_eq!(theme.colors.success, Some(0x9ece6a));
1975        assert_eq!(theme.colors.chart_palette.len(), 4);
1976    }
1977
1978    #[test]
1979    fn test_theme_with_light_base() {
1980        let source = r##"
1981            plugin = { name = "light-theme" }
1982
1983            theme = {
1984                name = "my-light",
1985                base = "light",
1986                colors = {
1987                    bg_base = "#ffffff",
1988                    text_primary = "#000000"
1989                }
1990            }
1991        "##;
1992
1993        let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
1994
1995        let themes = plugin.themes();
1996        assert_eq!(themes.len(), 1);
1997        assert_eq!(themes[0].base, ThemeBase::Light);
1998        assert_eq!(themes[0].colors.bg_base, Some(0xffffff));
1999        assert_eq!(themes[0].colors.text_primary, Some(0x000000));
2000    }
2001
2002    #[test]
2003    fn test_plugin_without_theme() {
2004        let source = r#"
2005            plugin = { name = "no-theme" }
2006
2007            enya.register_command("test", {}, function() return true end)
2008        "#;
2009
2010        let plugin = LuaPlugin::load_from_source(source, &PathBuf::from("test.lua")).unwrap();
2011
2012        // Should not have CUSTOM_THEMES capability
2013        assert!(
2014            !plugin
2015                .capabilities()
2016                .contains(PluginCapabilities::CUSTOM_THEMES)
2017        );
2018
2019        // themes() should return empty
2020        assert!(plugin.themes().is_empty());
2021    }
2022}