Packages
Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.
Current section
Files
Jump to
Current section
Files
lib/raxol/terminal/plugin/plugin_server.ex
defmodule Raxol.Terminal.Plugin.PluginServer do
@moduledoc """
Unified plugin system for the Raxol terminal emulator.
Handles themes, scripting, and extensions.
Refactored version with pure functional error handling patterns.
All try/catch blocks have been replaced with with statements and proper error tuples.
"""
use Raxol.Core.Behaviours.BaseManager
alias Raxol.Core.Runtime.Log
# Types
@type plugin_id :: String.t()
@type plugin_type :: :theme | :script | :extension
@type plugin_state :: %{
id: plugin_id(),
type: plugin_type(),
name: String.t(),
version: String.t(),
description: String.t(),
author: String.t(),
dependencies: [String.t()],
config: map(),
status: :active | :inactive | :error,
error: String.t() | nil
}
# Client API
# BaseManager provides start_link/1 automatically with name: __MODULE__ as default
@doc """
Loads a plugin from a file or directory.
"""
def load_plugin(path, type, opts \\ []) do
GenServer.call(__MODULE__, {:load_plugin, path, type, opts})
end
@doc """
Unloads a plugin by ID.
"""
def unload_plugin(plugin_id) do
GenServer.call(__MODULE__, {:unload_plugin, plugin_id})
end
@doc """
Gets the state of a plugin.
"""
def get_plugin_state(plugin_id) do
GenServer.call(__MODULE__, {:get_plugin_state, plugin_id})
end
@doc """
Gets all loaded plugins.
"""
def get_plugins(opts \\ []) do
GenServer.call(__MODULE__, {:get_plugins, opts})
end
@doc """
Updates a plugin's configuration.
"""
def update_plugin_config(plugin_id, config) do
GenServer.call(__MODULE__, {:update_plugin_config, plugin_id, config})
end
@doc """
Executes a plugin function.
"""
def execute_plugin_function(plugin_id, function, args \\ []) do
GenServer.call(
__MODULE__,
{:execute_plugin_function, plugin_id, function, args}
)
end
@doc """
Reloads a plugin.
"""
def reload_plugin(plugin_id) do
GenServer.call(__MODULE__, {:reload_plugin, plugin_id})
end
# Server Callbacks
@impl true
def init_manager(opts) do
opts_map = Map.new(opts)
state = %{
plugins: %{},
plugin_paths: Map.get(opts_map, :plugin_paths, []),
auto_load: Map.get(opts_map, :auto_load, true),
plugin_config: Map.get(opts_map, :plugin_config, %{})
}
auto_load_plugins_if_enabled(state.auto_load, state.plugin_paths)
{:ok, state}
end
@impl true
def handle_manager_call({:load_plugin, path, type, opts}, _from, state) do
case do_load_plugin(path, type, opts, state) do
{:ok, plugin_id, plugin_state} ->
new_state = put_in(state.plugins[plugin_id], plugin_state)
{:reply, {:ok, plugin_id}, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
@impl true
def handle_manager_call({:unload_plugin, plugin_id}, _from, state) do
case do_unload_plugin(plugin_id, state) do
{:ok, new_state} ->
{:reply, :ok, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
@impl true
def handle_manager_call({:get_plugin_state, plugin_id}, _from, state) do
case Map.get(state.plugins, plugin_id) do
nil -> {:reply, {:error, :plugin_not_found}, state}
plugin_state -> {:reply, {:ok, plugin_state}, state}
end
end
@impl true
def handle_manager_call({:get_plugins, opts}, _from, state) do
plugins = filter_plugins(state.plugins, opts)
{:reply, {:ok, plugins}, state}
end
@impl true
def handle_manager_call(
{:update_plugin_config, plugin_id, config},
_from,
state
) do
case do_update_plugin_config(plugin_id, config, state) do
{:ok, new_state} ->
{:reply, :ok, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
@impl true
def handle_manager_call(
{:execute_plugin_function, plugin_id, function, args},
_from,
state
) do
case Map.get(state.plugins, plugin_id) do
nil ->
{:reply, {:error, :plugin_not_found}, state}
plugin_state ->
result = execute_function(plugin_state, function, args)
{:reply, result, state}
end
end
@impl true
def handle_manager_call({:reload_plugin, plugin_id}, _from, state) do
case do_reload_plugin(plugin_id, state) do
{:ok, new_state} ->
{:reply, :ok, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
{:error, reason, new_state} ->
{:reply, {:error, reason, new_state}, new_state}
end
end
# Private Functions
defp do_load_plugin(path, type, opts, state) do
with {:ok, plugin_state} <- load_plugin_state(path, type, opts),
:ok <- validate_plugin(plugin_state),
:ok <- check_dependencies(plugin_state, state.plugins) do
{:ok, plugin_state.id, plugin_state}
else
{:error, reason} -> {:error, reason}
end
end
defp do_unload_plugin(plugin_id, state) do
case Map.get(state.plugins, plugin_id) do
nil ->
{:error, :plugin_not_found}
plugin_state ->
with :ok <- cleanup_plugin(plugin_state) do
new_plugins = Map.delete(state.plugins, plugin_id)
{:ok, %{state | plugins: new_plugins}}
else
{:error, reason} -> {:error, reason}
end
end
end
defp do_update_plugin_config(plugin_id, config, state) do
case Map.get(state.plugins, plugin_id) do
nil ->
{:error, :plugin_not_found}
plugin_state ->
with :ok <- validate_plugin_config(config) do
updated_plugin = %{plugin_state | config: config}
new_plugins = Map.put(state.plugins, plugin_id, updated_plugin)
{:ok, %{state | plugins: new_plugins}}
else
{:error, reason} -> {:error, reason}
end
end
end
defp do_reload_plugin(plugin_id, state) do
case Map.get(state.plugins, plugin_id) do
nil ->
{:error, :plugin_not_found}
plugin_state ->
case reload_plugin_attempt(plugin_state, state.plugins) do
{:ok, new_plugin_state} ->
new_plugins = Map.put(state.plugins, plugin_id, new_plugin_state)
{:ok, %{state | plugins: new_plugins}}
{:error, reason} ->
error_plugin_state = %{
plugin_state
| status: :error,
error: inspect(reason)
}
new_plugins = Map.put(state.plugins, plugin_id, error_plugin_state)
new_state = %{state | plugins: new_plugins}
{:error, :reload_failed, new_state}
end
end
end
defp reload_plugin_attempt(plugin_state, loaded_plugins) do
with :ok <- cleanup_plugin(plugin_state),
{:ok, new_plugin_state} <-
load_plugin_state(
plugin_state.path,
plugin_state.type,
plugin_state.config
),
:ok <- validate_plugin(new_plugin_state),
:ok <- check_dependencies(new_plugin_state, loaded_plugins) do
{:ok, new_plugin_state}
else
{:error, reason} -> {:error, reason}
end
end
defp generate_plugin_id(path) do
{:ok, :crypto.hash(:sha256, path) |> Base.encode16()}
end
defp load_plugin_state(path, type, opts) do
case type do
:theme -> load_theme_plugin(path, opts)
:script -> load_script_plugin(path, opts)
:extension -> load_extension_plugin(path, opts)
_ -> {:error, :invalid_plugin_type}
end
end
defp validate_plugin(plugin_state) do
required_fields = [:id, :type, :name, :version, :description, :author]
case Enum.all?(required_fields, &Map.has_key?(plugin_state, &1)) do
true -> :ok
false -> {:error, :invalid_plugin_format}
end
end
defp check_dependencies(plugin_state, loaded_plugins) do
# Check if all dependencies exist by looking up plugin names
dependency_satisfied? = fn dep_name ->
Enum.any?(loaded_plugins, fn {_id, plugin} ->
plugin.name == dep_name and plugin.status == :active
end)
end
case Enum.all?(plugin_state.dependencies, dependency_satisfied?) do
true -> :ok
false -> {:error, :module_not_found}
end
end
defp cleanup_plugin(plugin_state) do
case plugin_state.type do
:theme -> cleanup_theme_plugin(plugin_state)
:script -> cleanup_script_plugin(plugin_state)
:extension -> cleanup_extension_plugin(plugin_state)
end
end
defp validate_plugin_config(config) do
case is_map(config) do
true -> :ok
false -> {:error, :invalid_config_format}
end
end
defp execute_function(plugin_state, function, args) do
case plugin_state.type do
:theme -> execute_theme_function(plugin_state, function, args)
:script -> execute_script_function(plugin_state, function, args)
:extension -> execute_extension_function(plugin_state, function, args)
end
end
defp filter_plugins(plugins, opts) do
plugins
|> Enum.filter(fn {_id, plugin} ->
Enum.all?(opts, fn
{:type, type} -> plugin.type == type
{:status, status} -> plugin.status == status
_ -> true
end)
end)
|> Map.new()
end
defp load_plugins_from_paths(paths) do
Enum.each(paths, &process_plugin_path/1)
end
defp process_plugin_path(path) do
case File.ls(path) do
{:ok, files} -> Enum.each(files, &process_plugin_file(path, &1))
{:error, reason} -> log_plugin_directory_error(path, reason)
end
end
defp process_plugin_file(directory, file) do
file_path = Path.join(directory, file)
with {:ok, type} <- determine_plugin_type(file),
{:ok, _plugin_id} <- load_plugin(file_path, type) do
:ok
else
{:error, reason} ->
Log.debug("Skipping #{file}: #{inspect(reason)}")
end
end
defp determine_plugin_type(file) do
Enum.find_value(
[
{".theme.json", :theme},
{".script.ex", :script},
{".extension", :extension}
],
{:error, :unknown_type},
fn {extension, type} ->
check_file_extension(file, extension, type)
end
)
end
defp log_plugin_directory_error(path, reason) do
Log.error("Failed to read plugin directory #{path}: #{inspect(reason)}")
end
# Theme Plugin Functions
defp load_theme_plugin(path, opts) do
opts = normalize_opts_to_list(opts)
with {:ok, theme_config} <- load_theme_config(path),
{:ok, theme_module} <- load_theme_module(path),
{:ok, plugin_id} <- generate_plugin_id(path) do
theme_state = %{
id: plugin_id,
type: :theme,
name: Keyword.get(opts, :name, theme_config[:name] || "Unnamed Theme"),
version: Keyword.get(opts, :version, theme_config[:version] || "1.0.0"),
description:
Keyword.get(opts, :description, theme_config[:description] || ""),
author: Keyword.get(opts, :author, theme_config[:author] || "Unknown"),
dependencies:
Keyword.get(opts, :dependencies, theme_config[:dependencies] || []),
config: theme_config,
status: :active,
error: nil,
module: theme_module,
path: path
}
{:ok, theme_state}
else
{:error, reason} -> {:error, reason}
end
end
defp load_theme_config(path) do
config_path = get_theme_config_path(path)
with {:ok, content} <- File.read(config_path),
{:ok, config} <- safe_json_decode(content) do
{:ok, config}
else
{:error, reason} -> {:error, reason}
end
end
defp safe_json_decode(content) do
Raxol.Core.ErrorHandling.safe_call(fn ->
case Jason.decode(content, keys: :atoms) do
{:ok, decoded} -> {:ok, decoded}
{:error, _} -> {:error, :invalid_json}
end
end)
|> case do
{:ok, result} -> result
{:error, _} -> {:error, :json_decode_failed}
end
end
defp load_theme_module(path) do
module_path = get_theme_module_path(path)
load_module_if_exists(module_path)
end
defp safe_load_module(path) do
with {:ok, content} <- File.read(path),
{:ok, module} <- safe_compile_module(content) do
{:ok, module}
else
error -> error
end
end
defp safe_compile_module(content) do
case Code.string_to_quoted(content) do
{:ok, ast} ->
case safe_compile_quoted(ast) do
{:ok, module} -> {:ok, module}
error -> error
end
{:error, _} ->
{:error, :invalid_code}
end
end
defp safe_compile_quoted(ast) do
compiled = Code.compile_quoted(ast)
Raxol.Core.ErrorHandling.safe_call(fn ->
case compiled do
[{module, _bin} | _] -> {:ok, module}
_ -> {:error, :compilation_failed}
end
end)
|> case do
{:ok, result} ->
result
{:error, error} ->
Log.error("Compilation failed: #{inspect(error)}")
{:error, :compilation_failed}
end
end
defp cleanup_theme_plugin(plugin_state) do
case plugin_state.module do
nil ->
:ok
module ->
safe_call_cleanup(module, plugin_state.config, "theme")
end
end
defp safe_call_cleanup(module, config, plugin_type) do
call_cleanup_if_exported(module, config, plugin_type)
end
defp execute_theme_function(plugin_state, function, args) do
case plugin_state.module do
nil ->
{:error, :module_not_loaded}
module ->
safe_execute_function(module, function, args, "theme")
end
end
defp safe_execute_function(module, function, args, plugin_type) do
execute_function_if_exported(module, function, args, plugin_type)
end
# Script Plugin Functions
defp load_script_plugin(path, opts) do
opts = normalize_opts(opts)
case handle_script_path(path) do
{:ok, script_path} -> load_script_from_file(script_path, opts)
{:error, reason} -> {:error, reason}
end
end
defp normalize_opts(opts) do
normalize_opts_format(opts)
end
defp handle_script_path(path) do
case {File.dir?(path), File.exists?(path)} do
{true, _} -> find_script_in_directory(path)
{false, false} -> {:error, :file_not_found}
{false, true} -> {:ok, path}
end
end
defp find_script_in_directory(path) do
script_file = find_script_file(path)
validate_script_file(script_file)
end
defp find_script_file(path) do
Enum.find_value(
["script.ex", "script.exs"],
nil,
fn filename ->
full_path = Path.join(path, filename)
check_script_file_exists(full_path)
end
)
end
defp load_script_from_file(path, opts) do
with {:ok, script_content} <- File.read(path),
{:ok, script_module} <- compile_script(script_content),
{:ok, plugin_id} <- generate_plugin_id(path) do
script_state = build_script_state(plugin_id, script_module, path, opts)
{:ok, script_state}
else
{:error, reason} -> {:error, reason}
end
end
defp build_script_state(plugin_id, script_module, path, opts) do
%{
id: plugin_id,
type: :script,
name: Keyword.get(opts, :name, "Unnamed Script"),
version: Keyword.get(opts, :version, "1.0.1"),
description: Keyword.get(opts, :description, ""),
author: Keyword.get(opts, :author, "Unknown"),
dependencies: Keyword.get(opts, :dependencies, []),
config: Keyword.get(opts, :config, %{}),
status: :active,
error: nil,
module: script_module,
path: path
}
end
defp compile_script(content) do
safe_compile_module(content)
end
defp cleanup_script_plugin(plugin_state) do
case plugin_state.module do
nil ->
:ok
module ->
safe_call_cleanup(module, plugin_state.config, "script")
end
end
defp execute_script_function(plugin_state, function, args) do
case plugin_state.module do
nil ->
{:error, :module_not_loaded}
module ->
safe_execute_function(module, function, args, "script")
end
end
# Extension Plugin Functions
defp load_extension_plugin(path, opts) do
opts = normalize_opts_to_list(opts)
with {:ok, extension_config} <- load_extension_config(path),
{:ok, extension_module} <- load_extension_module(path),
{:ok, plugin_id} <- generate_plugin_id(path) do
extension_state = %{
id: plugin_id,
type: :extension,
name: Keyword.get(opts, :name, "Unnamed Extension"),
version: Keyword.get(opts, :version, "1.0.1"),
description: Keyword.get(opts, :description, ""),
author: Keyword.get(opts, :author, "Unknown"),
dependencies: Keyword.get(opts, :dependencies, []),
config: extension_config,
status: :active,
error: nil,
module: extension_module,
path: path
}
{:ok, extension_state}
else
{:error, reason} -> {:error, reason}
end
end
defp load_extension_config(path) do
config_path = get_extension_config_path(path)
with {:ok, content} <- File.read(config_path),
{:ok, config} <- safe_json_decode(content) do
{:ok, config}
else
{:error, _reason} ->
# Default config if file doesn't exist
{:ok, %{}}
end
end
defp load_extension_module(path) do
module_path = get_extension_module_path(path)
load_module_if_exists(module_path)
end
defp cleanup_extension_plugin(plugin_state) do
case plugin_state.module do
nil ->
:ok
module ->
safe_call_cleanup(module, plugin_state.config, "extension")
end
end
defp execute_extension_function(plugin_state, function, args) do
case plugin_state.module do
nil ->
{:error, :module_not_loaded}
module ->
safe_execute_function(module, function, args, "extension")
end
end
# Safe function application helper using functional error handling
defp safe_apply(module, function, args) do
Raxol.Core.ErrorHandling.safe_call(fn ->
apply(module, function, args)
end)
|> case do
{:ok, result} -> {:ok, result}
{:error, {:exit, reason}} -> {:error, {:exit, reason}}
{:error, {:throw, thrown}} -> {:error, {:throw, thrown}}
{:error, error} -> {:error, error}
end
end
## Helper functions for refactored if statements
defp auto_load_plugins_if_enabled(true, plugin_paths) do
load_plugins_from_paths(plugin_paths)
end
defp auto_load_plugins_if_enabled(false, _plugin_paths), do: :ok
defp check_file_extension(file, extension, type) do
case String.ends_with?(file, extension) do
true -> type
false -> nil
end
end
defp get_theme_config_path(path) do
case File.dir?(path) do
true -> Path.join(path, "theme.json")
false -> Path.dirname(path) |> Path.join("theme.json")
end
end
defp get_theme_module_path(path) do
case File.dir?(path) do
true -> Path.join(path, "theme.ex")
false -> Path.dirname(path) |> Path.join("theme.ex")
end
end
defp load_module_if_exists(module_path) do
case File.exists?(module_path) do
true -> safe_load_module(module_path)
false -> {:ok, nil}
end
end
defp call_cleanup_if_exported(module, config, plugin_type) do
case function_exported?(module, :cleanup, 1) do
true ->
Raxol.Core.ErrorHandling.safe_call(fn -> module.cleanup(config) end)
|> case do
{:ok, result} ->
result
{:error, error} ->
Log.error("#{plugin_type} cleanup failed: #{inspect(error)}")
{:error, :cleanup_failed}
end
false ->
:ok
end
end
defp execute_function_if_exported(module, function, args, plugin_type) do
case function_exported?(module, function, length(args)) do
true ->
safe_apply(module, function, args)
false ->
Log.warning(
"Function #{function}/#{length(args)} not exported from #{plugin_type} module"
)
{:error, :function_not_exported}
end
end
defp normalize_opts_format(opts) when is_map(opts) do
Enum.into(opts, [])
end
defp normalize_opts_format(opts), do: opts
defp normalize_opts_to_list(opts) when is_map(opts) do
Enum.into(opts, [])
end
defp normalize_opts_to_list(opts), do: opts
defp validate_script_file(nil), do: {:error, :script_not_found}
defp validate_script_file(script_file), do: {:ok, script_file}
defp check_script_file_exists(full_path) do
case File.exists?(full_path) do
true -> full_path
false -> nil
end
end
defp get_extension_config_path(path) do
case File.dir?(path) do
true -> Path.join(path, "extension.json")
false -> Path.dirname(path) |> Path.join("extension.json")
end
end
defp get_extension_module_path(path) do
case File.dir?(path) do
true -> Path.join(path, "extension.ex")
false -> Path.dirname(path) |> Path.join("extension.ex")
end
end
end